Skip to main content

max / makenotwork

17.2 KB · 542 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 Ok(makenotwork::payments::ProviderAccountId::from_provider(
256 "acct_test_mock".to_string(),
257 ))
258 }
259
260 async fn get_balance(
261 &self,
262 _account_id: &str,
263 _currency: makenotwork::currency::SettlementCurrency,
264 ) -> Result<BalanceSummary> {
265 self.faults.check("get_balance")?;
266 Ok(BalanceSummary {
267 available_cents: 0,
268 pending_cents: 0,
269 })
270 }
271
272 fn verify_webhook(
273 &self,
274 payload: &str,
275 signature: &str,
276 ) -> Result<makenotwork::payments::UntypedEvent> {
277 self.faults.check("verify_webhook")?;
278 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET)
279 .map_err(AppError::BadRequest)?;
280 makenotwork::payments::UntypedEvent::from_payload(payload)
281 }
282
283 fn normalize_webhook(
284 &self,
285 event: makenotwork::payments::UntypedEvent,
286 ) -> Result<makenotwork::payments::MnwEvent> {
287 self.faults.check("normalize_webhook")?;
288 // The mock stands in for Stripe, so it maps Stripe's wire names. Same
289 // reason `verify_webhook` above reuses the real `verify_signature`.
290 makenotwork::payments::normalize_event(&event.type_, event.data_object)
291 }
292
293 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
294 self.faults.check("verify_webhook_v2")?;
295 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET_V2)
296 .map_err(AppError::BadRequest)?;
297 serde_json::from_str(payload)
298 .map_err(|e| AppError::BadRequest(format!("Invalid payload: {e}")))
299 }
300
301 async fn pause_subscription(
302 &self,
303 _stripe_sub_id: &str,
304 _connected_account_id: &str,
305 ) -> Result<()> {
306 self.faults.check("pause_subscription")?;
307 Ok(())
308 }
309
310 async fn resume_subscription(
311 &self,
312 _stripe_sub_id: &str,
313 _connected_account_id: &str,
314 ) -> Result<()> {
315 self.faults.check("resume_subscription")?;
316 Ok(())
317 }
318
319 async fn cancel_subscription(
320 &self,
321 _stripe_sub_id: &str,
322 _connected_account_id: &str,
323 ) -> Result<()> {
324 self.faults.check("cancel_subscription")?;
325 Ok(())
326 }
327
328 async fn set_cancel_at_period_end(
329 &self,
330 _stripe_sub_id: &str,
331 _connected_account_id: &str,
332 _cancel: bool,
333 ) -> Result<()> {
334 self.faults.check("set_cancel_at_period_end")?;
335 Ok(())
336 }
337
338 async fn cancel_platform_subscription(&self, _stripe_sub_id: &str) -> Result<()> {
339 self.faults.check("cancel_platform_subscription")?;
340 Ok(())
341 }
342
343 async fn set_platform_cancel_at_period_end(
344 &self,
345 _stripe_sub_id: &str,
346 _cancel: bool,
347 ) -> Result<()> {
348 self.faults.check("set_platform_cancel_at_period_end")?;
349 Ok(())
350 }
351
352 async fn update_synckit_subscription_price(
353 &self,
354 _subscription_id: &str,
355 _new_price_cents: i64,
356 _app_name: &str,
357 ) -> Result<()> {
358 self.faults.check("update_synckit_subscription_price")?;
359 Ok(())
360 }
361
362 async fn update_synckit_app_sub_price(
363 &self,
364 _subscription_id: &str,
365 _new_price_cents: i64,
366 _interval: makenotwork::payments::SyncBillingInterval,
367 _product_name: &str,
368 ) -> Result<()> {
369 self.faults.check("update_synckit_app_sub_price")?;
370 Ok(())
371 }
372
373 async fn create_synckit_app_sub_checkout_session(
374 &self,
375 _params: &makenotwork::payments::SynckitAppSubCheckoutParams<'_>,
376 ) -> Result<CheckoutResult> {
377 self.faults
378 .check("create_synckit_app_sub_checkout_session")?;
379 Ok(self.next_session())
380 }
381
382 async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> {
383 self.faults.check("cancel_synckit_subscription")?;
384 Ok(())
385 }
386 }
387
388 #[async_trait::async_trait]
389 impl HostedPortal for MockPaymentProvider {
390 async fn create_billing_portal_session(
391 &self,
392 _stripe_customer_id: &str,
393 return_url: &str,
394 ) -> Result<String> {
395 self.faults.check("create_billing_portal_session")?;
396 // Echo a deterministic URL so tests can assert the redirect target.
397 Ok(format!(
398 "https://billing.stripe.test/portal?return={}",
399 urlencoding::encode(return_url)
400 ))
401 }
402
403 async fn create_synckit_billing_portal(
404 &self,
405 _customer_id: &str,
406 return_url: &str,
407 ) -> Result<String> {
408 self.faults.check("create_synckit_billing_portal")?;
409 Ok(format!(
410 "https://billing.stripe.test/portal?return={}",
411 urlencoding::encode(return_url)
412 ))
413 }
414 }
415
416 #[async_trait::async_trait]
417 impl ConnectOnboarding for MockPaymentProvider {
418 async fn create_account_link(
419 &self,
420 _account_id: &str,
421 _return_url: &str,
422 _refresh_url: &str,
423 ) -> Result<String> {
424 self.faults.check("create_account_link")?;
425 Ok("https://connect.stripe.com/test/onboarding".to_string())
426 }
427
428 async fn fetch_account(&self, account_id: &str) -> Result<AccountUpdate> {
429 self.faults.check("fetch_account")?;
430 Ok(AccountUpdate {
431 account_id: account_id.to_string(),
432 charges_enabled: true,
433 payouts_enabled: true,
434 details_submitted: true,
435 settlement_currency: Some(makenotwork::currency::SettlementCurrency::Usd),
436 })
437 }
438 }
439
440 #[async_trait::async_trait]
441 impl Catalogue for MockPaymentProvider {
442 async fn create_subscription_product_and_price(
443 &self,
444 _connected_account_id: &str,
445 _tier_name: &str,
446 _tier_description: Option<&str>,
447 _price_cents: i64,
448 _currency: makenotwork::currency::SettlementCurrency,
449 ) -> Result<(String, String)> {
450 self.faults.check("create_subscription_product_and_price")?;
451 Ok(("prod_test_mock".to_string(), "price_test_mock".to_string()))
452 }
453 }
454
455 #[async_trait::async_trait]
456 impl Refundable for MockPaymentProvider {
457 async fn create_refund_for_transaction(
458 &self,
459 payment_intent_id: &str,
460 _connected_account_id: &str,
461 amount_cents: i64,
462 transaction_id: makenotwork::db::TransactionId,
463 ) -> Result<()> {
464 self.faults.check("create_refund_for_transaction")?;
465 self.refunds.lock().unwrap().push(MockRefund {
466 payment_intent_id: payment_intent_id.to_string(),
467 amount_cents,
468 transaction_id,
469 });
470 Ok(())
471 }
472 }
473
474 #[async_trait::async_trait]
475 impl PlatformTransfers for MockPaymentProvider {
476 async fn create_platform_credit_transfer(
477 &self,
478 connected_account_id: &str,
479 amount_cents: i64,
480 transaction_id: makenotwork::db::TransactionId,
481 _currency: makenotwork::currency::SettlementCurrency,
482 ) -> Result<String> {
483 self.faults.check("create_platform_credit_transfer")?;
484 self.transfers.lock().unwrap().push(MockTransfer {
485 connected_account_id: connected_account_id.to_string(),
486 amount_cents,
487 transaction_id,
488 });
489 // Deterministic dummy transfer id, the reversal path stores and reuses it.
490 Ok(format!("tr_mock_{transaction_id}"))
491 }
492
493 async fn create_platform_credit_reversal(
494 &self,
495 transfer_id: &str,
496 amount_cents: i64,
497 transaction_id: makenotwork::db::TransactionId,
498 ) -> Result<()> {
499 self.faults.check("create_platform_credit_reversal")?;
500 self.reversals.lock().unwrap().push(MockReversal {
501 transfer_id: transfer_id.to_string(),
502 amount_cents,
503 transaction_id,
504 });
505 Ok(())
506 }
507 }
508
509 #[async_trait::async_trait]
510 impl CustodialCustomers for MockPaymentProvider {
511 async fn create_synckit_customer(
512 &self,
513 _developer_user_id: makenotwork::db::UserId,
514 _app_id: makenotwork::db::SyncAppId,
515 _email: &str,
516 _app_name: &str,
517 ) -> Result<String> {
518 self.faults.check("create_synckit_customer")?;
519 // Deterministic dummy; tests assert on shape, not content.
520 Ok("cus_test_synckit".to_string())
521 }
522
523 async fn create_synckit_subscription(
524 &self,
525 _customer_id: &str,
526 app_id: makenotwork::db::SyncAppId,
527 _app_name: &str,
528 _price_cents: i64,
529 ) -> Result<makenotwork::payments::SynckitSubResult> {
530 self.faults.check("create_synckit_subscription")?;
531 let now = SystemTime::now()
532 .duration_since(UNIX_EPOCH)
533 .unwrap()
534 .as_secs() as i64;
535 Ok(makenotwork::payments::SynckitSubResult {
536 subscription_id: format!("sub_test_{app_id}"),
537 current_period_start: now,
538 current_period_end: now + 30 * 24 * 60 * 60,
539 })
540 }
541 }
542