Skip to main content

max / makenotwork

17.7 KB · 551 lines History Blame Raw
1 //! Stripe test helpers, webhook signature computation and mock payment provider.
2
3 use super::faults::Faults;
4 use hmac::{Hmac, KeyInit, Mac};
5 use sha2::Sha256;
6 use std::sync::Mutex;
7 use std::time::{SystemTime, UNIX_EPOCH};
8
9 use makenotwork::error::{AppError, Result};
10 use makenotwork::payments::{
11 AccountUpdate, BalanceSummary, Catalogue, CheckoutParams, CheckoutResult, ConnectOnboarding,
12 CustodialCustomers, HostedPortal, PaymentProvider, PlatformTransfers, Refundable,
13 SubscriptionCheckoutParams, TipCheckoutParams,
14 };
15
16 #[allow(dead_code)]
17 type HmacSha256 = Hmac<Sha256>;
18
19 /// Known test webhook secret used by the test harness `with_stripe()` builder.
20 #[allow(dead_code)]
21 pub(crate) const TEST_WEBHOOK_SECRET: &str = "whsec_test_secret";
22
23 /// Known test webhook secret for v2 thin events.
24 #[allow(dead_code)]
25 pub(crate) const TEST_WEBHOOK_SECRET_V2: &str = "whsec_test_secret_v2";
26
27 /// Compute a valid `Stripe-Signature` header value for the given payload.
28 ///
29 /// Mirrors Stripe's signing scheme:
30 /// signed_payload = "{timestamp}.{payload}"
31 /// signature = HMAC-SHA256(secret, signed_payload)
32 /// header = "t={timestamp},v1={hex(signature)}"
33 #[allow(dead_code)]
34 pub(crate) fn sign_webhook_payload(payload: &str, secret: &str) -> String {
35 let timestamp = SystemTime::now()
36 .duration_since(UNIX_EPOCH)
37 .unwrap()
38 .as_secs();
39
40 sign_webhook_payload_with_timestamp(payload, secret, timestamp)
41 }
42
43 /// Like [`sign_webhook_payload`] but with an explicit timestamp (seconds since epoch).
44 #[allow(dead_code)]
45 pub(crate) fn sign_webhook_payload_with_timestamp(
46 payload: &str,
47 secret: &str,
48 timestamp: u64,
49 ) -> String {
50 let signed_payload = format!("{timestamp}.{payload}");
51
52 let mut mac =
53 HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
54 mac.update(signed_payload.as_bytes());
55 let result = mac.finalize();
56 let hex_sig = hex::encode(result.into_bytes());
57
58 format!("t={timestamp},v1={hex_sig}")
59 }
60
61 /// Record of a checkout session created by the mock.
62 #[derive(Debug, Clone)]
63 #[allow(dead_code)]
64 pub(crate) struct MockCheckout {
65 pub id: String,
66 pub url: String,
67 }
68
69 /// Mock payment provider for integration tests.
70 ///
71 /// Returns predictable fake data for all operations. Records checkout
72 /// creations so tests can assert on them. Webhook verification uses the
73 /// test webhook secrets defined above.
74 pub(crate) struct MockPaymentProvider {
75 checkouts: Mutex<Vec<MockCheckout>>,
76 next_checkout_id: Mutex<u64>,
77 /// `trial_days` passed to each creator-tier checkout, in call order. Lets
78 /// the comp-code test assert the trial was actually threaded to Stripe.
79 creator_tier_trial_days: Mutex<Vec<Option<i32>>>,
80 /// Line-scoped refunds requested, in call order. Lets tests assert a cart
81 /// line refund hits Stripe for only that line's amount + transaction id.
82 refunds: Mutex<Vec<MockRefund>>,
83 /// Platform-funded credit transfers requested, in call order. Lets the Fan+
84 /// reimbursement tests assert the creator was made whole.
85 transfers: Mutex<Vec<MockTransfer>>,
86 /// Platform-credit reversals requested, in call order. Lets refund tests
87 /// assert a settled credit was clawed back.
88 reversals: Mutex<Vec<MockReversal>>,
89 /// Injected Stripe failures. Empty by default. Operations are named for the
90 /// trait method, so a rule reads as the Stripe call it breaks.
91 faults: Faults,
92 }
93
94 /// A line-scoped refund captured by the mock.
95 #[derive(Debug, Clone)]
96 #[allow(dead_code)]
97 pub(crate) struct MockRefund {
98 pub payment_intent_id: String,
99 pub amount_cents: i64,
100 pub transaction_id: makenotwork::db::TransactionId,
101 }
102
103 /// A platform-funded credit transfer captured by the mock.
104 #[derive(Debug, Clone)]
105 #[allow(dead_code)]
106 pub(crate) struct MockTransfer {
107 pub connected_account_id: String,
108 pub amount_cents: i64,
109 pub transaction_id: makenotwork::db::TransactionId,
110 }
111
112 /// A platform-credit reversal captured by the mock.
113 #[derive(Debug, Clone)]
114 #[allow(dead_code)]
115 pub(crate) struct MockReversal {
116 pub transfer_id: String,
117 pub amount_cents: i64,
118 pub transaction_id: makenotwork::db::TransactionId,
119 }
120
121 #[allow(dead_code)]
122 impl MockPaymentProvider {
123 pub(crate) fn new() -> Self {
124 MockPaymentProvider {
125 checkouts: Mutex::new(Vec::new()),
126 next_checkout_id: Mutex::new(1),
127 creator_tier_trial_days: Mutex::new(Vec::new()),
128 refunds: Mutex::new(Vec::new()),
129 transfers: Mutex::new(Vec::new()),
130 reversals: Mutex::new(Vec::new()),
131 faults: Faults::new(),
132 }
133 }
134
135 /// The failure policy. Install rules on it to reach the compensation paths,
136 /// `db/pending_refunds.rs` above all, that a provider which never fails
137 /// leaves unobserved.
138 pub(crate) fn faults(&self) -> &Faults {
139 &self.faults
140 }
141
142 /// All line-scoped refunds requested so far.
143 pub(crate) fn refunds(&self) -> Vec<MockRefund> {
144 self.refunds.lock().unwrap().clone()
145 }
146
147 /// All platform-funded credit transfers requested so far.
148 pub(crate) fn transfers(&self) -> Vec<MockTransfer> {
149 self.transfers.lock().unwrap().clone()
150 }
151
152 /// All platform-credit reversals requested so far.
153 pub(crate) fn reversals(&self) -> Vec<MockReversal> {
154 self.reversals.lock().unwrap().clone()
155 }
156
157 /// Return all checkouts created so far.
158 pub(crate) fn checkouts(&self) -> Vec<MockCheckout> {
159 self.checkouts.lock().unwrap().clone()
160 }
161
162 /// `trial_days` recorded for each creator-tier checkout, in call order.
163 pub(crate) fn creator_tier_trial_days(&self) -> Vec<Option<i32>> {
164 self.creator_tier_trial_days.lock().unwrap().clone()
165 }
166
167 fn next_session(&self) -> CheckoutResult {
168 let mut counter = self.next_checkout_id.lock().unwrap();
169 let id = format!("cs_test_{}", *counter);
170 let url = format!("https://checkout.stripe.com/test/{id}");
171 *counter += 1;
172 self.checkouts.lock().unwrap().push(MockCheckout {
173 id: id.clone(),
174 url: url.clone(),
175 });
176 CheckoutResult { id, url: Some(url) }
177 }
178 }
179
180 #[async_trait::async_trait]
181 impl PaymentProvider for MockPaymentProvider {
182 async fn create_checkout_session(
183 &self,
184 _params: &CheckoutParams<'_>,
185 ) -> Result<CheckoutResult> {
186 self.faults.check("create_checkout_session")?;
187 Ok(self.next_session())
188 }
189
190 async fn create_guest_checkout_session(
191 &self,
192 _params: &makenotwork::payments::GuestCheckoutParams<'_>,
193 ) -> Result<CheckoutResult> {
194 self.faults.check("create_guest_checkout_session")?;
195 Ok(self.next_session())
196 }
197
198 async fn create_subscription_checkout_session(
199 &self,
200 _params: &SubscriptionCheckoutParams<'_>,
201 ) -> Result<CheckoutResult> {
202 self.faults.check("create_subscription_checkout_session")?;
203 Ok(self.next_session())
204 }
205
206 async fn create_tip_checkout_session(
207 &self,
208 _params: &TipCheckoutParams<'_>,
209 ) -> Result<CheckoutResult> {
210 self.faults.check("create_tip_checkout_session")?;
211 Ok(self.next_session())
212 }
213
214 async fn create_fan_plus_checkout_session(
215 &self,
216 _price_id: &str,
217 _user_id: makenotwork::db::UserId,
218 _success_url: &str,
219 _cancel_url: &str,
220 ) -> Result<CheckoutResult> {
221 self.faults.check("create_fan_plus_checkout_session")?;
222 Ok(self.next_session())
223 }
224
225 async fn create_creator_tier_checkout_session(
226 &self,
227 _price_id: &str,
228 _user_id: makenotwork::db::UserId,
229 _tier: &str,
230 _success_url: &str,
231 _cancel_url: &str,
232 trial_days: Option<i32>,
233 ) -> Result<CheckoutResult> {
234 self.faults.check("create_creator_tier_checkout_session")?;
235 self.creator_tier_trial_days
236 .lock()
237 .unwrap()
238 .push(trial_days);
239 Ok(self.next_session())
240 }
241
242 async fn create_cart_checkout_session(
243 &self,
244 _params: &makenotwork::payments::CartCheckoutParams<'_>,
245 ) -> Result<CheckoutResult> {
246 self.faults.check("create_cart_checkout_session")?;
247 Ok(self.next_session())
248 }
249
250 async fn create_connect_account(
251 &self,
252 _email: &str,
253 ) -> Result<makenotwork::payments::ProviderAccountId> {
254 self.faults.check("create_connect_account")?;
255 // Stripe mints `acct_` plus an alphanumeric token, and the server
256 // validates that shape before storing it, so an id with an underscore
257 // in the tail (`acct_test_mock`) is rejected at the boundary and the
258 // whole Connect onboarding path is unreachable from a test.
259 //
260 // Numbered per call, so a handler that creates a second account where
261 // it should have reused the first shows up as two different ids rather
262 // than as the same constant twice.
263 let n = self.faults.calls("create_connect_account");
264 Ok(makenotwork::payments::ProviderAccountId::from_provider(
265 format!("acct_{n:016}testmock"),
266 ))
267 }
268
269 async fn get_balance(
270 &self,
271 _account_id: &str,
272 _currency: makenotwork::currency::SettlementCurrency,
273 ) -> Result<BalanceSummary> {
274 self.faults.check("get_balance")?;
275 Ok(BalanceSummary {
276 available_cents: 0,
277 pending_cents: 0,
278 })
279 }
280
281 fn verify_webhook(
282 &self,
283 payload: &str,
284 signature: &str,
285 ) -> Result<makenotwork::payments::UntypedEvent> {
286 self.faults.check("verify_webhook")?;
287 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET)
288 .map_err(AppError::BadRequest)?;
289 makenotwork::payments::UntypedEvent::from_payload(payload)
290 }
291
292 fn normalize_webhook(
293 &self,
294 event: makenotwork::payments::UntypedEvent,
295 ) -> Result<makenotwork::payments::MnwEvent> {
296 self.faults.check("normalize_webhook")?;
297 // The mock stands in for Stripe, so it maps Stripe's wire names. Same
298 // reason `verify_webhook` above reuses the real `verify_signature`.
299 makenotwork::payments::normalize_event(&event.type_, event.data_object)
300 }
301
302 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
303 self.faults.check("verify_webhook_v2")?;
304 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET_V2)
305 .map_err(AppError::BadRequest)?;
306 serde_json::from_str(payload)
307 .map_err(|e| AppError::BadRequest(format!("Invalid payload: {e}")))
308 }
309
310 async fn pause_subscription(
311 &self,
312 _stripe_sub_id: &str,
313 _connected_account_id: &str,
314 ) -> Result<()> {
315 self.faults.check("pause_subscription")?;
316 Ok(())
317 }
318
319 async fn resume_subscription(
320 &self,
321 _stripe_sub_id: &str,
322 _connected_account_id: &str,
323 ) -> Result<()> {
324 self.faults.check("resume_subscription")?;
325 Ok(())
326 }
327
328 async fn cancel_subscription(
329 &self,
330 _stripe_sub_id: &str,
331 _connected_account_id: &str,
332 ) -> Result<()> {
333 self.faults.check("cancel_subscription")?;
334 Ok(())
335 }
336
337 async fn set_cancel_at_period_end(
338 &self,
339 _stripe_sub_id: &str,
340 _connected_account_id: &str,
341 _cancel: bool,
342 ) -> Result<()> {
343 self.faults.check("set_cancel_at_period_end")?;
344 Ok(())
345 }
346
347 async fn cancel_platform_subscription(&self, _stripe_sub_id: &str) -> Result<()> {
348 self.faults.check("cancel_platform_subscription")?;
349 Ok(())
350 }
351
352 async fn set_platform_cancel_at_period_end(
353 &self,
354 _stripe_sub_id: &str,
355 _cancel: bool,
356 ) -> Result<()> {
357 self.faults.check("set_platform_cancel_at_period_end")?;
358 Ok(())
359 }
360
361 async fn update_synckit_subscription_price(
362 &self,
363 _subscription_id: &str,
364 _new_price_cents: i64,
365 _app_name: &str,
366 ) -> Result<()> {
367 self.faults.check("update_synckit_subscription_price")?;
368 Ok(())
369 }
370
371 async fn update_synckit_app_sub_price(
372 &self,
373 _subscription_id: &str,
374 _new_price_cents: i64,
375 _interval: makenotwork::payments::SyncBillingInterval,
376 _product_name: &str,
377 ) -> Result<()> {
378 self.faults.check("update_synckit_app_sub_price")?;
379 Ok(())
380 }
381
382 async fn create_synckit_app_sub_checkout_session(
383 &self,
384 _params: &makenotwork::payments::SynckitAppSubCheckoutParams<'_>,
385 ) -> Result<CheckoutResult> {
386 self.faults
387 .check("create_synckit_app_sub_checkout_session")?;
388 Ok(self.next_session())
389 }
390
391 async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> {
392 self.faults.check("cancel_synckit_subscription")?;
393 Ok(())
394 }
395 }
396
397 #[async_trait::async_trait]
398 impl HostedPortal for MockPaymentProvider {
399 async fn create_billing_portal_session(
400 &self,
401 _stripe_customer_id: &str,
402 return_url: &str,
403 ) -> Result<String> {
404 self.faults.check("create_billing_portal_session")?;
405 // Echo a deterministic URL so tests can assert the redirect target.
406 Ok(format!(
407 "https://billing.stripe.test/portal?return={}",
408 urlencoding::encode(return_url)
409 ))
410 }
411
412 async fn create_synckit_billing_portal(
413 &self,
414 _customer_id: &str,
415 return_url: &str,
416 ) -> Result<String> {
417 self.faults.check("create_synckit_billing_portal")?;
418 Ok(format!(
419 "https://billing.stripe.test/portal?return={}",
420 urlencoding::encode(return_url)
421 ))
422 }
423 }
424
425 #[async_trait::async_trait]
426 impl ConnectOnboarding for MockPaymentProvider {
427 async fn create_account_link(
428 &self,
429 _account_id: &str,
430 _return_url: &str,
431 _refresh_url: &str,
432 ) -> Result<String> {
433 self.faults.check("create_account_link")?;
434 Ok("https://connect.stripe.com/test/onboarding".to_string())
435 }
436
437 async fn fetch_account(&self, account_id: &str) -> Result<AccountUpdate> {
438 self.faults.check("fetch_account")?;
439 Ok(AccountUpdate {
440 account_id: account_id.to_string(),
441 charges_enabled: true,
442 payouts_enabled: true,
443 details_submitted: true,
444 settlement_currency: Some(makenotwork::currency::SettlementCurrency::Usd),
445 })
446 }
447 }
448
449 #[async_trait::async_trait]
450 impl Catalogue for MockPaymentProvider {
451 async fn create_subscription_product_and_price(
452 &self,
453 _connected_account_id: &str,
454 _tier_name: &str,
455 _tier_description: Option<&str>,
456 _price_cents: i64,
457 _currency: makenotwork::currency::SettlementCurrency,
458 ) -> Result<(String, String)> {
459 self.faults.check("create_subscription_product_and_price")?;
460 Ok(("prod_test_mock".to_string(), "price_test_mock".to_string()))
461 }
462 }
463
464 #[async_trait::async_trait]
465 impl Refundable for MockPaymentProvider {
466 async fn create_refund_for_transaction(
467 &self,
468 payment_intent_id: &str,
469 _connected_account_id: &str,
470 amount_cents: i64,
471 transaction_id: makenotwork::db::TransactionId,
472 ) -> Result<()> {
473 self.faults.check("create_refund_for_transaction")?;
474 self.refunds.lock().unwrap().push(MockRefund {
475 payment_intent_id: payment_intent_id.to_string(),
476 amount_cents,
477 transaction_id,
478 });
479 Ok(())
480 }
481 }
482
483 #[async_trait::async_trait]
484 impl PlatformTransfers for MockPaymentProvider {
485 async fn create_platform_credit_transfer(
486 &self,
487 connected_account_id: &str,
488 amount_cents: i64,
489 transaction_id: makenotwork::db::TransactionId,
490 _currency: makenotwork::currency::SettlementCurrency,
491 ) -> Result<String> {
492 self.faults.check("create_platform_credit_transfer")?;
493 self.transfers.lock().unwrap().push(MockTransfer {
494 connected_account_id: connected_account_id.to_string(),
495 amount_cents,
496 transaction_id,
497 });
498 // Deterministic dummy transfer id, the reversal path stores and reuses it.
499 Ok(format!("tr_mock_{transaction_id}"))
500 }
501
502 async fn create_platform_credit_reversal(
503 &self,
504 transfer_id: &str,
505 amount_cents: i64,
506 transaction_id: makenotwork::db::TransactionId,
507 ) -> Result<()> {
508 self.faults.check("create_platform_credit_reversal")?;
509 self.reversals.lock().unwrap().push(MockReversal {
510 transfer_id: transfer_id.to_string(),
511 amount_cents,
512 transaction_id,
513 });
514 Ok(())
515 }
516 }
517
518 #[async_trait::async_trait]
519 impl CustodialCustomers for MockPaymentProvider {
520 async fn create_synckit_customer(
521 &self,
522 _developer_user_id: makenotwork::db::UserId,
523 _app_id: makenotwork::db::SyncAppId,
524 _email: &str,
525 _app_name: &str,
526 ) -> Result<String> {
527 self.faults.check("create_synckit_customer")?;
528 // Deterministic dummy; tests assert on shape, not content.
529 Ok("cus_test_synckit".to_string())
530 }
531
532 async fn create_synckit_subscription(
533 &self,
534 _customer_id: &str,
535 app_id: makenotwork::db::SyncAppId,
536 _app_name: &str,
537 _price_cents: i64,
538 ) -> Result<makenotwork::payments::SynckitSubResult> {
539 self.faults.check("create_synckit_subscription")?;
540 let now = SystemTime::now()
541 .duration_since(UNIX_EPOCH)
542 .unwrap()
543 .as_secs() as i64;
544 Ok(makenotwork::payments::SynckitSubResult {
545 subscription_id: format!("sub_test_{app_id}"),
546 current_period_start: now,
547 current_period_end: now + 30 * 24 * 60 * 60,
548 })
549 }
550 }
551