Skip to main content

max / makenotwork

server: contract tests for payments::refund
Author: Max Johnson <me@maxj.phd> · 2026-08-27 01:08 UTC
Signed with PGP, not checked
Commit: 24716c5f8e4ff3fb52f3d659fcde170b7a441c9b
Parent: fca1e31
2 files changed, +501 insertions, -0 deletions
@@ -90,6 +90,7 @@
90 90 mod password_reset;
91 91 mod patches;
92 92 mod payment_crash_recovery;
93 + mod payments_refund_layer;
93 94 mod postmark;
94 95 mod preferences;
95 96 mod project_content_panel;
@@ -1,0 +1,506 @@
1 + //! Layer contract tests for `payments::refund`, the creator-initiated refund.
2 + //!
3 + //! The module was extracted on 2026-08-26 because two callers need it: the
4 + //! axum wrapper at `routes::api::items::refund_transaction` and the described
5 + //! Sales panel's writes-only nest. Extracting it moved every authorization
6 + //! check off the callers and into one function, which is the right shape and
7 + //! also means the checks are now asserted nowhere the callers do not 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::PaymentProvider;
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 PaymentProvider> {
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.
Lines truncated