Skip to main content

max / makenotwork

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