Skip to main content

max / makenotwork

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