//! Layer contract tests for `payments::refund`, the creator-initiated refund. //! //! Two callers reach it: the axum wrapper at //! `routes::api::items::refund_transaction` and the described Sales panel's //! writes-only nest. Every authorization check lives in the one function rather //! than in the callers, so the checks are asserted nowhere the callers do not //! reach. //! //! `mock_payment_flows.rs` drives four of these through HTTP, which is the //! route's contract rather than the module's. These call `refund` directly, so //! each guard is pinned where it lives: ownership, the transaction belonging to //! this item and this seller, refundable status, the payment intent, the //! connected account, the single-shot claim, and the claim release when Stripe //! rejects. use crate::harness::TestHarness; use crate::harness::faults::stripe_unavailable; use makenotwork::auth::SessionUser; use makenotwork::db::{self, ItemId, TransactionId}; use makenotwork::error::AppError; use makenotwork::payments::Refundable; use makenotwork::payments::refund::refund; use serde_json::Value; use std::sync::Arc; /// A creator with Stripe connected, one published paid item, logged in. /// /// Deliberately its own copy rather than a shared helper: the usernames have to /// differ from the other suites' or the signup collides, and a helper that took /// a prefix would be longer than the thing it saved. async fn setup(h: &mut TestHarness, price_cents: i32) -> (db::UserId, String, String) { let seller_id = h .signup("prlseller", "prlseller@test.com", "pass1234") .await; h.grant_creator(seller_id).await; sqlx::query( "UPDATE users SET stripe_account_id = 'acct_mock_prlseller', \ stripe_charges_enabled = true WHERE id = $1", ) .bind(seller_id) .execute(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("prlseller", "pass1234").await; let resp = h .client .post_form("/api/projects", "slug=prlshop&title=PRL+Shop") .await; let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), &format!("title=PRL+Track&price_cents={price_cents}&item_type=audio"), ) .await; let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); h.client .put_form(&format!("/api/projects/{project_id}"), "is_public=true") .await; (seller_id, project_id, item_id) } /// Insert a transaction against this item and return its id. /// /// Takes the seller explicitly so a test can write a row whose `seller_id` is /// somebody else, which is the only way to reach the seller check: the /// ownership check in front of it passes on the item, not on the row. async fn insert_transaction( h: &TestHarness, buyer_id: db::UserId, seller_id: db::UserId, item_id: &str, amount_cents: i32, status: &str, payment_intent: Option<&str>, ) -> TransactionId { let id: uuid::Uuid = sqlx::query_scalar( "INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status, stripe_payment_intent_id, stripe_checkout_session_id, item_title, seller_username, completed_at) VALUES ($1, $2, $3::uuid, $4, $5, $6, 'cs_prl', 'PRL Track', 'prlseller', NOW()) RETURNING id", ) .bind(buyer_id) .bind(seller_id) .bind(item_id) .bind(amount_cents) .bind(status) .bind(payment_intent) .fetch_one(&h.db) .await .unwrap(); TransactionId::from(id) } async fn session_user(h: &TestHarness, id: db::UserId) -> SessionUser { let row = db::users::get_user_by_id(&h.db, id) .await .unwrap() .expect("seeded user exists"); SessionUser::from_db_user(row, &h.db, None).await } fn item(id: &str) -> ItemId { ItemId::from(uuid::Uuid::parse_str(id).unwrap()) } async fn status_of(h: &TestHarness, tx: TransactionId) -> String { sqlx::query_scalar("SELECT status FROM transactions WHERE id = $1") .bind(uuid::Uuid::from(tx)) .fetch_one(&h.db) .await .unwrap() } fn provider(h: &TestHarness) -> Arc { h.mock_stripe.clone().expect("harness built with mocks") } // The happy path, and what it sends #[tokio::test] async fn refund_sends_this_line_only_and_claims_the_row() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_happy"), ) .await; let seller = session_user(&h, seller_id).await; refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect("a completed transaction with a payment intent refunds"); // Line-scoped: this transaction's amount and id, not the PaymentIntent's // total. A cart order puts every line under one PI, so refunding the PI // would silently reverse the whole order. let mock = h.mock_stripe.clone().unwrap(); let sent = mock.refunds(); assert_eq!(sent.len(), 1, "exactly one refund reaches Stripe"); assert_eq!(sent[0].payment_intent_id, "pi_prl_happy"); assert_eq!(sent[0].amount_cents, 999); assert_eq!(sent[0].transaction_id, tx); // Claimed, not yet refunded: the `refund.created` webhook finalizes it. assert_eq!(status_of(&h, tx).await, "refunding"); } // The guards, each reached on its own #[tokio::test] async fn refund_rejects_a_transaction_on_another_item() { let mut h = TestHarness::with_mocks().await; let (seller_id, project_id, item_id) = setup(&mut h, 999).await; // A second item in the same project, so it is owned by the same seller and // the ownership check in front passes. The item-match check is then the // only thing that can refuse. // // Created before the buyer signs up, because `signup` logs the new user in // and this is the one test here that still needs the seller's session. let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=PRL+Other+Track&price_cents=999&item_type=audio", ) .await; assert_eq!( resp.status, 200, "second item: {} {}", resp.status, resp.text ); let other: Value = resp.json(); let other_item = other["id"].as_str().unwrap().to_string(); let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &other_item, 999, "completed", Some("pi_prl_wrong_item"), ) .await; let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("a transaction on another item is not this item's to refund"); assert!(matches!(err, AppError::Forbidden), "got {err:?}"); assert!(h.mock_stripe.clone().unwrap().refunds().is_empty()); } #[tokio::test] async fn refund_rejects_a_transaction_sold_by_someone_else() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let other_seller = h.signup("prlother", "prlother@test.com", "pass1234").await; // The row is on this seller's item but records a different seller. Item // ownership passes; the seller check is the one that has to catch it. let tx = insert_transaction( &h, buyer_id, other_seller, &item_id, 999, "completed", Some("pi_prl_wrong_seller"), ) .await; let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("a transaction sold by someone else is not refundable here"); assert!(matches!(err, AppError::Forbidden), "got {err:?}"); assert!(h.mock_stripe.clone().unwrap().refunds().is_empty()); } #[tokio::test] async fn refund_rejects_a_transaction_that_is_not_completed() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "pending", Some("pi_prl_pending"), ) .await; let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("a pending transaction has nothing to refund"); assert!( matches!(&err, AppError::BadRequest(m) if m.contains("refundable state")), "got {err:?}" ); assert_eq!(status_of(&h, tx).await, "pending", "the row is untouched"); } #[tokio::test] async fn refund_rejects_a_free_claim_with_no_payment_intent() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 0).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction(&h, buyer_id, seller_id, &item_id, 0, "completed", None).await; let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("there is no money to send back on a free claim"); assert!( matches!(&err, AppError::BadRequest(m) if m.contains("free claims")), "got {err:?}" ); assert_eq!(status_of(&h, tx).await, "completed", "the row is untouched"); } #[tokio::test] async fn refund_rejects_a_seller_with_no_connected_account() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_no_acct"), ) .await; // Disconnected after the sale, which is the real sequence: the transaction // is old and legitimate, and the account it settled to is gone. sqlx::query("UPDATE users SET stripe_account_id = NULL WHERE id = $1") .bind(seller_id) .execute(&h.db) .await .unwrap(); let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("a refund needs an account to draw from"); assert!( matches!(&err, AppError::BadRequest(m) if m.contains("Stripe account")), "got {err:?}" ); assert_eq!(status_of(&h, tx).await, "completed", "the row is untouched"); } #[tokio::test] async fn refund_rejects_a_suspended_seller() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_suspended"), ) .await; sqlx::query("UPDATE users SET suspended_at = NOW() WHERE id = $1") .bind(seller_id) .execute(&h.db) .await .unwrap(); let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("a suspended creator does not move money"); assert!(matches!(err, AppError::Forbidden), "got {err:?}"); assert!(h.mock_stripe.clone().unwrap().refunds().is_empty()); assert_eq!(status_of(&h, tx).await, "completed", "the row is untouched"); } #[tokio::test] async fn refund_without_a_configured_provider_is_service_unavailable() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_no_provider"), ) .await; let seller = session_user(&h, seller_id).await; let err = refund(&h.db, None, &seller, item(&item_id), tx) .await .expect_err("no provider is not a silent success"); assert!( matches!(err, AppError::ServiceUnavailable(_)), "got {err:?}" ); // Checked before the claim, so a deployment with Stripe unconfigured cannot // strand a row in `refunding` with nothing in flight to finalize it. assert_eq!(status_of(&h, tx).await, "completed"); } // The claim, which is the guard that is not an authorization check #[tokio::test] async fn a_second_refund_in_a_row_is_refused_by_the_status_check() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_sequential"), ) .await; let seller = session_user(&h, seller_id).await; refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect("the first refund goes through"); // Sequentially the claim is never reached: the first call left the row in // `refunding`, so the status check above it refuses. Worth pinning because // it is the guard a creator clicking twice actually meets, and because it // is NOT the guard the claim exists for -- see the concurrent test below. let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("a row already refunding is not refundable again"); assert!( matches!(&err, AppError::BadRequest(m) if m.contains("refundable state")), "got {err:?}" ); assert_eq!(h.mock_stripe.clone().unwrap().refunds().len(), 1); } #[tokio::test] async fn refund_is_single_shot_under_concurrent_submits() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_concurrent"), ) .await; // This is the race the claim was added for (Pay-S1, Run 9). The status // check is a non-locking read and the row stays `completed` until the async // `refund.created` webhook lands, so two submits close enough together both // pass it. On a shared-cart PaymentIntent the loser would then consume // another line's refundable balance. let seller = session_user(&h, seller_id).await; let p = provider(&h); let (first, second) = tokio::join!( refund(&h.db, Some(&p), &seller, item(&item_id), tx), refund(&h.db, Some(&p), &seller, item(&item_id), tx), ); // Which one wins is genuinely racy; that exactly one does is the contract. assert_eq!( [first.is_ok(), second.is_ok()] .iter() .filter(|ok| **ok) .count(), 1, "exactly one concurrent submit succeeds: {first:?} / {second:?}" ); assert_eq!( h.mock_stripe.clone().unwrap().refunds().len(), 1, "Stripe is called once however the two interleave" ); assert_eq!(status_of(&h, tx).await, "refunding"); } #[tokio::test] async fn refund_releases_the_claim_when_stripe_rejects() { let mut h = TestHarness::with_mocks().await; let (seller_id, _project_id, item_id) = setup(&mut h, 999).await; let buyer_id = h.signup("prlbuyer", "prlbuyer@test.com", "pass1234").await; let tx = insert_transaction( &h, buyer_id, seller_id, &item_id, 999, "completed", Some("pi_prl_stripe_down"), ) .await; let mock = h.mock_stripe.clone().unwrap(); mock.faults() .fail_always("create_refund_for_transaction", stripe_unavailable); let seller = session_user(&h, seller_id).await; let err = refund(&h.db, Some(&provider(&h)), &seller, item(&item_id), tx) .await .expect_err("Stripe is down"); assert!( matches!(err, AppError::ServiceUnavailable(_)), "got {err:?}" ); // The compensation, and the reason this test exists: a claim that is not // released on failure leaves the row in `refunding` forever. No webhook is // coming, so nothing else would ever move it back, and the creator could // not retry. assert_eq!( status_of(&h, tx).await, "completed", "the claim is released so the creator can retry" ); }