Skip to main content

max / makenotwork

17.1 KB · 507 lines History Blame Raw
1 //! Layer contract tests for `payments::refund`, the creator-initiated refund.
2 //!
3 //! Two callers reach it: the axum wrapper at
4 //! `routes::api::items::refund_transaction` and the described Sales panel's
5 //! writes-only nest. Every authorization check lives in the one function rather
6 //! than in the callers, so the checks are asserted nowhere the callers do not
7 //! reach.
8 //!
9 //! `mock_payment_flows.rs` drives four of these through HTTP, which is the
10 //! route's contract rather than the module's. These call `refund` directly, so
11 //! each guard is pinned where it lives: ownership, the transaction belonging to
12 //! this item and this seller, refundable status, the payment intent, the
13 //! connected account, the single-shot claim, and the claim release when Stripe
14 //! rejects.
15
16 use crate::harness::TestHarness;
17 use crate::harness::faults::stripe_unavailable;
18 use makenotwork::auth::SessionUser;
19 use makenotwork::db::{self, ItemId, TransactionId};
20 use makenotwork::error::AppError;
21 use makenotwork::payments::Refundable;
22 use makenotwork::payments::refund::refund;
23 use serde_json::Value;
24 use std::sync::Arc;
25
26 /// A creator with Stripe connected, one published paid item, logged in.
27 ///
28 /// Deliberately its own copy rather than a shared helper: the usernames have to
29 /// differ from the other suites' or the signup collides, and a helper that took
30 /// a prefix would be longer than the thing it saved.
31 async fn setup(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) {
32 let seller_id = h
33 .signup("prlseller", "prlseller@test.com", "pass1234")
34 .await;
35 h.grant_creator(seller_id).await;
36
37 sqlx::query(
38 "UPDATE users SET stripe_account_id = 'acct_mock_prlseller', \
39 stripe_charges_enabled = true WHERE id = $1",
40 )
41 .bind(seller_id)
42 .execute(&h.db)
43 .await
44 .unwrap();
45
46 h.client.post_form("/logout", "").await;
47 h.login("prlseller", "pass1234").await;
48
49 let resp = h
50 .client
51 .post_form("/api/projects", "slug=prlshop&title=PRL+Shop")
52 .await;
53 let project: Value = resp.json();
54 let project_id = project["id"].as_str().unwrap().to_string();
55
56 let resp = h
57 .client
58 .post_form(
59 &format!("/api/projects/{project_id}/items"),
60 &format!("title=PRL+Track&price_cents={price_cents}&item_type=audio"),
61 )
62 .await;
63 let item: Value = resp.json();
64 let item_id = item["id"].as_str().unwrap().to_string();
65
66 h.client
67 .put_form(&format!("/api/projects/{project_id}"), "is_public=true")
68 .await;
69
70 (seller_id, project_id, item_id)
71 }
72
73 /// Insert a transaction against this item and return its id.
74 ///
75 /// Takes the seller explicitly so a test can write a row whose `seller_id` is
76 /// somebody else, which is the only way to reach the seller check: the
77 /// ownership check in front of it passes on the item, not on the row.
78 async fn insert_transaction(
79 h: &TestHarness,
80 buyer_id: db::UserId,
81 seller_id: db::UserId,
82 item_id: &str,
83 amount_cents: i32,
84 status: &str,
85 payment_intent: Option<&str>,
86 ) -> TransactionId {
87 let id: uuid::Uuid = sqlx::query_scalar(
88 "INSERT INTO transactions
89 (buyer_id, seller_id, item_id, amount_cents, status,
90 stripe_payment_intent_id, stripe_checkout_session_id,
91 item_title, seller_username, completed_at)
92 VALUES ($1, $2, $3::uuid, $4, $5, $6, 'cs_prl', 'PRL Track', 'prlseller', NOW())
93 RETURNING id",
94 )
95 .bind(buyer_id)
96 .bind(seller_id)
97 .bind(item_id)
98 .bind(amount_cents)
99 .bind(status)
100 .bind(payment_intent)
101 .fetch_one(&h.db)
102 .await
103 .unwrap();
104 TransactionId::from(id)
105 }
106
107 async fn session_user(h: &TestHarness, id: db::UserId) -> SessionUser {
108 let row = db::users::get_user_by_id(&h.db, id)
109 .await
110 .unwrap()
111 .expect("seeded user exists");
112 SessionUser::from_db_user(row, &h.db, None).await
113 }
114
115 fn item(id: &str) -> ItemId {
116 ItemId::from(uuid::Uuid::parse_str(id).unwrap())
117 }
118
119 async fn status_of(h: &TestHarness, tx: TransactionId) -> String {
120 sqlx::query_scalar("SELECT status FROM transactions WHERE id = $1")
121 .bind(uuid::Uuid::from(tx))
122 .fetch_one(&h.db)
123 .await
124 .unwrap()
125 }
126
127 fn provider(h: &TestHarness) -> Arc<dyn Refundable> {
128 h.mock_stripe.clone().expect("harness built with mocks")
129 }
130
131 // The happy path, and what it sends
132
133 #[tokio::test]
134 async fn refund_sends_this_line_only_and_claims_the_row() {
135 let mut h = TestHarness::with_mocks().await;
136 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
137 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
138 let tx = insert_transaction(
139 &h,
140 buyer_id,
141 seller_id,
142 &item_id,
143 999,
144 "completed",
145 Some("pi_prl_happy"),
146 )
147 .await;
148
149 let seller = session_user(&h, seller_id).await;
150 refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
151 .await
152 .expect("a completed transaction with a payment intent refunds");
153
154 // Line-scoped: this transaction's amount and id, not the PaymentIntent's
155 // total. A cart order puts every line under one PI, so refunding the PI
156 // would silently reverse the whole order.
157 let mock = h.mock_stripe.clone().unwrap();
158 let sent = mock.refunds();
159 assert_eq!(sent.len(), 1, "exactly one refund reaches Stripe");
160 assert_eq!(sent[0].payment_intent_id, "pi_prl_happy");
161 assert_eq!(sent[0].amount_cents, 999);
162 assert_eq!(sent[0].transaction_id, tx);
163
164 // Claimed, not yet refunded: the `refund.created` webhook finalizes it.
165 assert_eq!(status_of(&h, tx).await, "refunding");
166 }
167
168 // The guards, each reached on its own
169
170 #[tokio::test]
171 async fn refund_rejects_a_transaction_on_another_item() {
172 let mut h = TestHarness::with_mocks().await;
173 let (seller_id, project_id, item_id) = setup(&mut h, 999).await;
174
175 // A second item in the same project, so it is owned by the same seller and
176 // the ownership check in front passes. The item-match check is then the
177 // only thing that can refuse.
178 //
179 // Created before the buyer signs up, because `signup` logs the new user in
180 // and this is the one test here that still needs the seller's session.
181 let resp = h
182 .client
183 .post_form(
184 &format!("/api/projects/{project_id}/items"),
185 "title=PRL+Other+Track&price_cents=999&item_type=audio",
186 )
187 .await;
188 assert_eq!(
189 resp.status, 200,
190 "second item: {} {}",
191 resp.status, resp.text
192 );
193 let other: Value = resp.json();
194 let other_item = other["id"].as_str().unwrap().to_string();
195
196 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
197
198 let tx = insert_transaction(
199 &h,
200 buyer_id,
201 seller_id,
202 &other_item,
203 999,
204 "completed",
205 Some("pi_prl_wrong_item"),
206 )
207 .await;
208
209 let seller = session_user(&h, seller_id).await;
210 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
211 .await
212 .expect_err("a transaction on another item is not this item's to refund");
213 assert!(matches!(err, AppError::Forbidden), "got {err:?}");
214 assert!(h.mock_stripe.clone().unwrap().refunds().is_empty());
215 }
216
217 #[tokio::test]
218 async fn refund_rejects_a_transaction_sold_by_someone_else() {
219 let mut h = TestHarness::with_mocks().await;
220 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
221 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
222 let other_seller = h.signup("prlother", "prlother@test.com", "pass1234").await;
223
224 // The row is on this seller's item but records a different seller. Item
225 // ownership passes; the seller check is the one that has to catch it.
226 let tx = insert_transaction(
227 &h,
228 buyer_id,
229 other_seller,
230 &item_id,
231 999,
232 "completed",
233 Some("pi_prl_wrong_seller"),
234 )
235 .await;
236
237 let seller = session_user(&h, seller_id).await;
238 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
239 .await
240 .expect_err("a transaction sold by someone else is not refundable here");
241 assert!(matches!(err, AppError::Forbidden), "got {err:?}");
242 assert!(h.mock_stripe.clone().unwrap().refunds().is_empty());
243 }
244
245 #[tokio::test]
246 async fn refund_rejects_a_transaction_that_is_not_completed() {
247 let mut h = TestHarness::with_mocks().await;
248 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
249 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
250 let tx = insert_transaction(
251 &h,
252 buyer_id,
253 seller_id,
254 &item_id,
255 999,
256 "pending",
257 Some("pi_prl_pending"),
258 )
259 .await;
260
261 let seller = session_user(&h, seller_id).await;
262 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
263 .await
264 .expect_err("a pending transaction has nothing to refund");
265 assert!(
266 matches!(&err, AppError::BadRequest(m) if m.contains("refundable state")),
267 "got {err:?}"
268 );
269 assert_eq!(status_of(&h, tx).await, "pending", "the row is untouched");
270 }
271
272 #[tokio::test]
273 async fn refund_rejects_a_free_claim_with_no_payment_intent() {
274 let mut h = TestHarness::with_mocks().await;
275 let (seller_id, _project_id, item_id) = setup(&mut h, 0).await;
276 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
277 let tx = insert_transaction(&h, buyer_id, seller_id, &item_id, 0, "completed", None).await;
278
279 let seller = session_user(&h, seller_id).await;
280 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
281 .await
282 .expect_err("there is no money to send back on a free claim");
283 assert!(
284 matches!(&err, AppError::BadRequest(m) if m.contains("free claims")),
285 "got {err:?}"
286 );
287 assert_eq!(status_of(&h, tx).await, "completed", "the row is untouched");
288 }
289
290 #[tokio::test]
291 async fn refund_rejects_a_seller_with_no_connected_account() {
292 let mut h = TestHarness::with_mocks().await;
293 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
294 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
295 let tx = insert_transaction(
296 &h,
297 buyer_id,
298 seller_id,
299 &item_id,
300 999,
301 "completed",
302 Some("pi_prl_no_acct"),
303 )
304 .await;
305
306 // Disconnected after the sale, which is the real sequence: the transaction
307 // is old and legitimate, and the account it settled to is gone.
308 sqlx::query("UPDATE users SET stripe_account_id = NULL WHERE id = $1")
309 .bind(seller_id)
310 .execute(&h.db)
311 .await
312 .unwrap();
313
314 let seller = session_user(&h, seller_id).await;
315 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
316 .await
317 .expect_err("a refund needs an account to draw from");
318 assert!(
319 matches!(&err, AppError::BadRequest(m) if m.contains("Stripe account")),
320 "got {err:?}"
321 );
322 assert_eq!(status_of(&h, tx).await, "completed", "the row is untouched");
323 }
324
325 #[tokio::test]
326 async fn refund_rejects_a_suspended_seller() {
327 let mut h = TestHarness::with_mocks().await;
328 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
329 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
330 let tx = insert_transaction(
331 &h,
332 buyer_id,
333 seller_id,
334 &item_id,
335 999,
336 "completed",
337 Some("pi_prl_suspended"),
338 )
339 .await;
340
341 sqlx::query("UPDATE users SET suspended_at = NOW() WHERE id = $1")
342 .bind(seller_id)
343 .execute(&h.db)
344 .await
345 .unwrap();
346
347 let seller = session_user(&h, seller_id).await;
348 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
349 .await
350 .expect_err("a suspended creator does not move money");
351 assert!(matches!(err, AppError::Forbidden), "got {err:?}");
352 assert!(h.mock_stripe.clone().unwrap().refunds().is_empty());
353 assert_eq!(status_of(&h, tx).await, "completed", "the row is untouched");
354 }
355
356 #[tokio::test]
357 async fn refund_without_a_configured_provider_is_service_unavailable() {
358 let mut h = TestHarness::with_mocks().await;
359 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
360 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
361 let tx = insert_transaction(
362 &h,
363 buyer_id,
364 seller_id,
365 &item_id,
366 999,
367 "completed",
368 Some("pi_prl_no_provider"),
369 )
370 .await;
371
372 let seller = session_user(&h, seller_id).await;
373 let err = refund(&h.db, None, &seller, item(&item_id), tx)
374 .await
375 .expect_err("no provider is not a silent success");
376 assert!(
377 matches!(err, AppError::ServiceUnavailable(_)),
378 "got {err:?}"
379 );
380
381 // Checked before the claim, so a deployment with Stripe unconfigured cannot
382 // strand a row in `refunding` with nothing in flight to finalize it.
383 assert_eq!(status_of(&h, tx).await, "completed");
384 }
385
386 // The claim, which is the guard that is not an authorization check
387
388 #[tokio::test]
389 async fn a_second_refund_in_a_row_is_refused_by_the_status_check() {
390 let mut h = TestHarness::with_mocks().await;
391 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
392 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
393 let tx = insert_transaction(
394 &h,
395 buyer_id,
396 seller_id,
397 &item_id,
398 999,
399 "completed",
400 Some("pi_prl_sequential"),
401 )
402 .await;
403
404 let seller = session_user(&h, seller_id).await;
405 refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
406 .await
407 .expect("the first refund goes through");
408
409 // Sequentially the claim is never reached: the first call left the row in
410 // `refunding`, so the status check above it refuses. Worth pinning because
411 // it is the guard a creator clicking twice actually meets, and because it
412 // is NOT the guard the claim exists for -- see the concurrent test below.
413 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
414 .await
415 .expect_err("a row already refunding is not refundable again");
416 assert!(
417 matches!(&err, AppError::BadRequest(m) if m.contains("refundable state")),
418 "got {err:?}"
419 );
420 assert_eq!(h.mock_stripe.clone().unwrap().refunds().len(), 1);
421 }
422
423 #[tokio::test]
424 async fn refund_is_single_shot_under_concurrent_submits() {
425 let mut h = TestHarness::with_mocks().await;
426 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
427 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
428 let tx = insert_transaction(
429 &h,
430 buyer_id,
431 seller_id,
432 &item_id,
433 999,
434 "completed",
435 Some("pi_prl_concurrent"),
436 )
437 .await;
438
439 // This is the race the claim was added for (Pay-S1, Run 9). The status
440 // check is a non-locking read and the row stays `completed` until the async
441 // `refund.created` webhook lands, so two submits close enough together both
442 // pass it. On a shared-cart PaymentIntent the loser would then consume
443 // another line's refundable balance.
444 let seller = session_user(&h, seller_id).await;
445 let p = provider(&h);
446 let (first, second) = tokio::join!(
447 refund(&h.db, Some(&p), &seller, item(&item_id), tx),
448 refund(&h.db, Some(&p), &seller, item(&item_id), tx),
449 );
450
451 // Which one wins is genuinely racy; that exactly one does is the contract.
452 assert_eq!(
453 [first.is_ok(), second.is_ok()]
454 .iter()
455 .filter(|ok| **ok)
456 .count(),
457 1,
458 "exactly one concurrent submit succeeds: {first:?} / {second:?}"
459 );
460 assert_eq!(
461 h.mock_stripe.clone().unwrap().refunds().len(),
462 1,
463 "Stripe is called once however the two interleave"
464 );
465 assert_eq!(status_of(&h, tx).await, "refunding");
466 }
467
468 #[tokio::test]
469 async fn refund_releases_the_claim_when_stripe_rejects() {
470 let mut h = TestHarness::with_mocks().await;
471 let (seller_id, _project_id, item_id) = setup(&mut h, 999).await;
472 let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await;
473 let tx = insert_transaction(
474 &h,
475 buyer_id,
476 seller_id,
477 &item_id,
478 999,
479 "completed",
480 Some("pi_prl_stripe_down"),
481 )
482 .await;
483
484 let mock = h.mock_stripe.clone().unwrap();
485 mock.faults()
486 .fail_always("create_refund_for_transaction", stripe_unavailable);
487
488 let seller = session_user(&h, seller_id).await;
489 let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx)
490 .await
491 .expect_err("Stripe is down");
492 assert!(
493 matches!(err, AppError::ServiceUnavailable(_)),
494 "got {err:?}"
495 );
496
497 // The compensation, and the reason this test exists: a claim that is not
498 // released on failure leaves the row in `refunding` forever. No webhook is
499 // coming, so nothing else would ever move it back, and the creator could
500 // not retry.
501 assert_eq!(
502 status_of(&h, tx).await,
503 "completed",
504 "the claim is released so the creator can retry"
505 );
506 }
507