diff --git a/src/app.rs b/src/app.rs index df9679a..26070e0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -198,6 +198,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { "/economy/purchase-items", post(routes::economy::post_purchase_items), ) + .route( + "/economy/settle-sale", + post(routes::economy::post_settle_sale), + ) .route( "/collection/:owned_card_id", delete(routes::cards::delete_owned_card), diff --git a/src/routes/economy.rs b/src/routes/economy.rs index f3408a3..879032f 100644 --- a/src/routes/economy.rs +++ b/src/routes/economy.rs @@ -161,3 +161,60 @@ pub async fn post_purchase_items( let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?; Ok(Json(BalanceResponse { balance })) } + +/// `POST /economy/settle-sale` request. +/// +/// This is the ONE economy route that names clubs explicitly, and it has to: a +/// market sale has two sides, and the module's active-profile resolution can only +/// ever describe one. Both are optional and default to the game-scoped active +/// club, so the single-player case stays as terse as every other route: +/// +/// * `seller_club_id` omitted -> the active club is the seller (it listed the +/// item), which is the production shape. +/// * `buyer_club_id` omitted -> the counterparty is OUTSIDE the modelled +/// economy: no balance is debited and the item leaves the inventory. It does +/// NOT silently fall back to the active club, because that would settle a sale +/// between a club and itself. +#[derive(Deserialize)] +pub struct SettleSaleRequest { + /// The authoritative owned-item instance changing hands. + pub item_id: String, + /// What the buyer pays. The fee is withheld from this, never added to it. + pub gross: i64, + /// Withheld from the seller and destroyed. The RATE is a per-game policy the + /// caller owns; Core only checks `0 <= fee <= gross`. + pub fee: i64, + #[serde(default)] + pub seller_club_id: Option, + #[serde(default)] + pub buyer_club_id: Option, +} + +/// `POST /economy/settle-sale` — atomically debit the buyer, transfer the existing +/// item, credit the seller net of the fee, and destroy the fee. +pub async fn post_settle_sale( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + let seller = match req.seller_club_id { + Some(id) => id, + None => resolve_club(&state, &game).await?, + }; + let buyer = match req.buyer_club_id.as_deref() { + Some(id) => economy::SaleBuyer::Club(id), + None => economy::SaleBuyer::Outside, + }; + let receipt = economy::settle_sale( + &state.pool, + &req.item_id, + &seller, + buyer, + economy::SaleTerms { + gross: req.gross, + fee: req.fee, + }, + ) + .await?; + Ok(Json(receipt)) +} diff --git a/src/services/economy.rs b/src/services/economy.rs index 8041ddc..a4a7bb7 100644 --- a/src/services/economy.rs +++ b/src/services/economy.rs @@ -183,6 +183,68 @@ async fn remove_item( Ok(card_id) } +/// Drop `item_id` out of every lineup that references it. +/// +/// REQUIRED before an item changes owner or leaves the inventory, for two +/// independent reasons: +/// +/// * `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool +/// enables `foreign_keys`, so DELETEing a squadded item fails outright; +/// * on a transfer the row id is preserved, so a stale `squad_players` row +/// would leave the PREVIOUS owner fielding a card they no longer own. +/// +/// Every existing reference is invalid the moment ownership moves, so this is +/// scoped by item rather than by club. Returns the number of lineup slots freed. +async fn evict_from_squads(conn: &mut SqliteConnection, item_id: &str) -> AppResult { + Ok( + sqlx::query("DELETE FROM squad_players WHERE owned_card_id = ?") + .bind(item_id) + .execute(&mut *conn) + .await? + .rows_affected(), + ) +} + +/// Reassign one existing owned item from `from_club` to `to_club`, returning its +/// definition ref. +/// +/// This is a TRANSFER, not a mint: there is no INSERT, so the instance id and its +/// upgrade state survive and the inventory row count is unchanged. The +/// `club_id = from_club` predicate makes the UPDATE an ownership compare-and-swap +/// — if a concurrent settlement moved the item first, `rows_affected` is 0 and +/// this is a [`AppError::Conflict`] rather than a second transfer. +/// +/// Callers MUST have run [`evict_from_squads`] first: the row id is preserved, so +/// any surviving lineup reference would belong to the previous owner. +async fn transfer_item( + conn: &mut SqliteConnection, + item_id: &str, + from_club: &str, + to_club: &str, +) -> AppResult { + let card_id = sqlx::query_scalar::<_, String>( + "SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?", + ) + .bind(item_id) + .bind(from_club) + .fetch_optional(&mut *conn) + .await? + .ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?; + let affected = sqlx::query("UPDATE owned_cards SET club_id = ? WHERE id = ? AND club_id = ?") + .bind(to_club) + .bind(item_id) + .bind(from_club) + .execute(&mut *conn) + .await? + .rows_affected(); + if affected != 1 { + return Err(AppError::Conflict(format!( + "ownership of {item_id} changed during settlement" + ))); + } + Ok(card_id) +} + // ---- composed atomic operations ---------------------------------------------- /// Read a club's current currency balance. @@ -346,6 +408,155 @@ pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult< finish(&mut conn, result).await } +/// Who acquires the item in a settled sale. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SaleBuyer<'a> { + /// A club held by this Core: its balance is debited `gross` and it becomes + /// the item's owner. Coins move BETWEEN modelled balances, so the economy + /// only loses the fee. + Club(&'a str), + /// A counterparty outside the modelled economy (e.g. a synthetic market + /// buyer, which owns no `clubs` row). Nothing is debited and the item leaves + /// the inventory. The seller's proceeds therefore ENTER the economy from + /// outside — the accounting invariant differs from [`SaleBuyer::Club`] and + /// the caller is responsible for wanting that. + Outside, +} + +/// Money terms of a sale. `fee` is supplied by the caller, never computed here: +/// the rate is a per-game policy constant and Core is game-neutral. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SaleTerms { + /// What the buyer pays. + pub gross: i64, + /// Withheld from the seller and destroyed. `0 <= fee <= gross`. + pub fee: i64, +} + +/// What a settlement did, for the caller to render and for audit. +#[derive(Debug, Clone, Serialize)] +pub struct SaleReceipt { + pub item_id: String, + pub card_id: String, + pub seller_club_id: String, + pub buyer_club_id: Option, + pub gross: i64, + pub fee: i64, + /// `gross - fee`, credited to the seller. + pub proceeds: i64, + pub seller_balance: i64, + /// Post-debit buyer balance; `None` for [`SaleBuyer::Outside`]. + pub buyer_balance: Option, + /// Lineup slots freed because the item changed hands. + pub squad_slots_freed: u64, +} + +/// Settle a completed market sale as ONE atomic transaction: debit the buyer, +/// move the EXISTING item, credit the seller their proceeds, destroy the fee. +/// +/// `seller_club_id` is the club the CALLER believes owns the item, and every +/// ownership statement is predicated on it. Core deliberately does NOT infer the +/// seller from current ownership: doing so makes a replayed settlement look like a +/// brand-new sale by the item's new owner, and Core has no listing concept with +/// which to notice. Pinning the expected seller turns the ownership UPDATE into a +/// compare-and-swap that is the authority on "this sale has already happened", +/// independent of any caller-side listing state. A caller still cannot credit a +/// club that did not own the item, because the credit only follows a matched CAS. +/// +/// Ownership moves by UPDATE — no row is inserted or deleted on the +/// [`SaleBuyer::Club`] path, which is what structurally rules out the duplication +/// a mint-based "buy" causes. +/// +/// Fail-closed and all-or-nothing. Rejected without touching any balance: +/// negative `gross`/`fee`, `fee > gross`, an item the named seller does not own +/// (including a replay, where ownership has already moved), a buyer that cannot +/// afford `gross`, or a buyer that is already the seller (not a market path — it +/// would otherwise credit and debit the same club and destroy the fee for nothing). +/// +/// Ownership is judged BEFORE affordability, so a replay is reported as "not owned" +/// rather than as the buyer being broke from the sale that already succeeded. +/// +/// Coin conservation for [`SaleBuyer::Club`]: `gross` leaves the buyer, `gross - +/// fee` reaches the seller, and the economy shrinks by exactly `fee`. +pub async fn settle_sale( + pool: &Pool, + item_id: &str, + seller_club_id: &str, + buyer: SaleBuyer<'_>, + terms: SaleTerms, +) -> AppResult { + let SaleTerms { gross, fee } = terms; + if gross < 0 { + return Err(AppError::BadRequest( + "sale gross must be non-negative".into(), + )); + } + if fee < 0 { + return Err(AppError::BadRequest("sale fee must be non-negative".into())); + } + if fee > gross { + return Err(AppError::BadRequest(format!( + "sale fee {fee} exceeds gross {gross}" + ))); + } + if let SaleBuyer::Club(buyer_club) = buyer { + if buyer_club == seller_club_id { + return Err(AppError::Conflict(format!( + "buyer and seller are the same club: {buyer_club}" + ))); + } + } + let proceeds = gross - fee; + let mut conn = pool.acquire().await?; + sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?; + let result = async { + let (card_id, buyer_club_id, buyer_balance, squad_slots_freed) = match buyer { + SaleBuyer::Club(buyer_club) => { + // Ownership is checked FIRST, and the order matters for the REASON a + // rejection carries even though it cannot change the final state + // (everything here shares one transaction, so any error rolls the + // whole thing back either way). + // + // Debiting first made a replayed settlement fail as "insufficient + // balance": after a sale the buyer has already spent the coins, so the + // affordability guard fired before the ownership CAS was ever + // consulted. That is a misleading answer to "why was this refused", + // and a retry test written against it passes without ever exercising + // the replay guard it claims to test. + let freed = evict_from_squads(&mut conn, item_id).await?; + let card_id = transfer_item(&mut conn, item_id, seller_club_id, buyer_club).await?; + let buyer_balance = debit(&mut conn, buyer_club, gross).await?; + ( + card_id, + Some(buyer_club.to_string()), + Some(buyer_balance), + freed, + ) + } + SaleBuyer::Outside => { + let freed = evict_from_squads(&mut conn, item_id).await?; + let card_id = remove_item(&mut conn, seller_club_id, item_id).await?; + (card_id, None, None, freed) + } + }; + let seller_balance = credit(&mut conn, seller_club_id, proceeds).await?; + Ok(SaleReceipt { + item_id: item_id.to_string(), + card_id, + seller_club_id: seller_club_id.to_string(), + buyer_club_id, + gross, + fee, + proceeds, + seller_balance, + buyer_balance, + squad_slots_freed, + }) + } + .await; + finish(&mut conn, result).await +} + #[cfg(test)] mod tests { use super::*; @@ -409,6 +620,442 @@ mod tests { .unwrap() } + /// Two-party market fixture. Deliberately NOT the single-club `fixture()`: + /// settling a sale where the buyer is also the seller is not a market path and + /// would hide every ownership bug this suite exists to catch. + /// + /// Seller `club-a` holds 1_000 coins and owns `item-x`; buyer `club-b` holds + /// 20_000 and owns nothing. Profiles are seeded by raw SQL, which sidesteps the + /// one-profile-per-game guard in `services::profile`. + async fn market_fixture() -> Pool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + for (profile, club, coins) in [("prof-a", "club-a", 1_000i64), ("prof-b", "club-b", 20_000)] + { + sqlx::query( + "INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)", + ) + .bind(profile) + .bind(profile) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("profile"); + sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind(club) + .bind(profile) + .bind(club) + .bind(coins) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("club"); + } + sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)") + .bind("item-x") + .bind("club-a") + .bind("def-x") + .bind(TS) + .execute(&pool) + .await + .expect("owned card"); + pool + } + + async fn owner(pool: &Pool, item_id: &str) -> Option { + sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?") + .bind(item_id) + .fetch_optional(pool) + .await + .unwrap() + } + + /// Total coins across every modelled balance — the quantity a sale between two + /// clubs must reduce by EXACTLY the fee. + async fn total_coins(pool: &Pool) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs") + .fetch_one(pool) + .await + .unwrap() + } + + /// Put `item-x` in a squad owned by `club_id`, returning nothing. Used to prove + /// a sold card cannot stay in the previous owner's lineup. + async fn squad_up(pool: &Pool, club_id: &str, item_id: &str) { + sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, 'S', '4-4-2', ?, ?)") + .bind("squad-1") + .bind(club_id) + .bind(TS) + .bind(TS) + .execute(pool) + .await + .expect("squad"); + sqlx::query("INSERT INTO squad_players (id, squad_id, owned_card_id, position_index) VALUES (?, ?, ?, 0)") + .bind("sp-1") + .bind("squad-1") + .bind(item_id) + .execute(pool) + .await + .expect("squad player"); + } + + async fn squad_slot_count(pool: &Pool) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players") + .fetch_one(pool) + .await + .unwrap() + } + + const CANON_GROSS: i64 = 15_000; + const CANON_FEE: i64 = 750; + + /// THE canonical settlement: gross 15_000, fee 750, proceeds 14_250. + #[tokio::test] + async fn settle_sale_transfers_ownership_and_splits_coins() { + let pool = market_fixture().await; + assert_eq!(total_coins(&pool).await, 21_000); + + let receipt = settle_sale( + &pool, + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { + gross: CANON_GROSS, + fee: CANON_FEE, + }, + ) + .await + .expect("settlement"); + + assert_eq!( + receipt.seller_club_id, "club-a", + "seller derived from owner" + ); + assert_eq!(receipt.buyer_club_id.as_deref(), Some("club-b")); + assert_eq!(receipt.card_id, "def-x"); + assert_eq!(receipt.gross, 15_000); + assert_eq!(receipt.fee, 750); + assert_eq!(receipt.proceeds, 14_250); + assert_eq!(receipt.buyer_balance, Some(5_000), "20_000 - 15_000"); + assert_eq!(receipt.seller_balance, 15_250, "1_000 + 14_250"); + + assert_eq!(balance(&pool, "club-b").await.unwrap(), 5_000); + assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250); + assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b")); + // The decisive anti-duplication assertion: ONE authoritative instance, + // the same id as before. A mint-based buy would make this 2. + assert_eq!(item_count(&pool, "item-x").await, 1); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards") + .fetch_one(&pool) + .await + .unwrap(), + 1, + "no second row anywhere in the inventory" + ); + } + + /// buyer_debit == seller_credit + fee, and the economy shrinks by exactly the + /// fee. This is the invariant that catches a coin created or destroyed by a + /// rounding or ordering mistake. + #[tokio::test] + async fn sale_conserves_coins_minus_the_fee() { + let pool = market_fixture().await; + let before = total_coins(&pool).await; + let receipt = settle_sale( + &pool, + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { + gross: CANON_GROSS, + fee: CANON_FEE, + }, + ) + .await + .unwrap(); + let after = total_coins(&pool).await; + + assert_eq!(before, 21_000); + assert_eq!(after, 20_250); + assert_eq!(before - after, receipt.fee, "economy shrinks by the fee"); + assert_eq!( + receipt.gross, + receipt.proceeds + receipt.fee, + "buyer debit == seller credit + fee" + ); + } + + /// A sold card must leave the previous owner's lineup. Without eviction the + /// seller keeps fielding a card the buyer owns (and on the `Outside` path the + /// FK makes the delete fail outright). + #[tokio::test] + async fn sale_evicts_the_item_from_the_sellers_squad() { + let pool = market_fixture().await; + squad_up(&pool, "club-a", "item-x").await; + assert_eq!(squad_slot_count(&pool).await, 1); + + let receipt = settle_sale( + &pool, + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { + gross: 1_000, + fee: 50, + }, + ) + .await + .unwrap(); + + assert_eq!(receipt.squad_slots_freed, 1); + assert_eq!( + squad_slot_count(&pool).await, + 0, + "stale lineup slot survived" + ); + assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b")); + } + + /// Selling to a counterparty outside the modelled economy: the seller is paid + /// net and the item leaves the inventory. No club is debited, so the seller's + /// proceeds legitimately ENTER the economy. + #[tokio::test] + async fn settle_sale_to_outside_retires_the_item_and_pays_net() { + let pool = market_fixture().await; + squad_up(&pool, "club-a", "item-x").await; + + let receipt = settle_sale( + &pool, + "item-x", + "club-a", + SaleBuyer::Outside, + SaleTerms { + gross: CANON_GROSS, + fee: CANON_FEE, + }, + ) + .await + .unwrap(); + + assert_eq!(receipt.buyer_club_id, None); + assert_eq!(receipt.buyer_balance, None); + assert_eq!(receipt.seller_balance, 15_250); + assert_eq!(receipt.squad_slots_freed, 1); + assert_eq!(item_count(&pool, "item-x").await, 0, "item was retired"); + assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "untouched"); + // Buyer coins are not modelled, so total coins RISE by the proceeds here. + assert_eq!(total_coins(&pool).await, 21_000 + 14_250); + } + + /// Every invalid sale must leave the economy bit-identical. + #[tokio::test] + async fn invalid_sales_change_nothing() { + let terms = SaleTerms { + gross: CANON_GROSS, + fee: CANON_FEE, + }; + // (label, item, buyer, terms) -> must fail without mutating anything + let cases: Vec<(&str, &str, &str, SaleBuyer<'_>, SaleTerms)> = vec![ + ( + "buyer cannot afford", + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { + gross: 20_001, + fee: 0, + }, + ), + ( + "buyer is the seller", + "item-x", + "club-a", + SaleBuyer::Club("club-a"), + terms, + ), + ( + "item does not exist", + "ghost", + "club-a", + SaleBuyer::Club("club-b"), + terms, + ), + ( + "buyer club does not exist", + "item-x", + "club-a", + SaleBuyer::Club("ghost"), + terms, + ), + ( + "fee exceeds gross", + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { + gross: 100, + fee: 101, + }, + ), + ( + "negative fee", + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { + gross: 100, + fee: -1, + }, + ), + ( + "negative gross", + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { gross: -1, fee: 0 }, + ), + ( + "outside sale of a missing item", + "ghost", + "club-a", + SaleBuyer::Outside, + terms, + ), + ]; + for (label, item, seller, buyer, terms) in cases { + let pool = market_fixture().await; + let err = settle_sale(&pool, item, seller, buyer, terms).await; + assert!(err.is_err(), "{label}: expected rejection"); + assert_eq!(balance(&pool, "club-a").await.unwrap(), 1_000, "{label}"); + assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "{label}"); + assert_eq!( + owner(&pool, "item-x").await.as_deref(), + Some("club-a"), + "{label}: ownership moved on a rejected sale" + ); + assert_eq!(item_count(&pool, "item-x").await, 1, "{label}"); + } + } + + /// A zero-price sale is legal (a free transfer) and pays no fee. + #[tokio::test] + async fn zero_price_sale_is_a_free_transfer() { + let pool = market_fixture().await; + let receipt = settle_sale( + &pool, + "item-x", + "club-a", + SaleBuyer::Club("club-b"), + SaleTerms { gross: 0, fee: 0 }, + ) + .await + .unwrap(); + assert_eq!(receipt.proceeds, 0); + assert_eq!(total_coins(&pool).await, 21_000, "no coins moved"); + assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b")); + } + + /// Settling the SAME sale twice must not pay the seller twice, and must be + /// refused for the RIGHT reason: the item is no longer the seller's. + /// + /// Asserting only "the second call failed" is not enough, and this test used to + /// make exactly that mistake. When the buyer's debit ran first, a replay of the + /// canonical sale was rejected because the buyer had already spent the coins — + /// the ownership CAS was never reached, so the test passed while proving nothing + /// about replay safety. Both a same-price replay and a cheap AFFORDABLE replay + /// are checked, and the error must name ownership in each. + #[tokio::test] + async fn settling_the_same_sale_twice_pays_once() { + let pool = market_fixture().await; + let terms = SaleTerms { + gross: CANON_GROSS, + fee: CANON_FEE, + }; + settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms) + .await + .expect("first settlement"); + + for (label, replay) in [ + ("same price", terms), + // Trivially affordable out of the 5_000 the buyer has left, so ownership + // is the ONLY thing that can refuse it. + ("affordable", SaleTerms { gross: 100, fee: 5 }), + ] { + let again = + settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), replay).await; + assert!( + matches!(again, Err(AppError::NotFound(_))), + "{label} replay must be refused on OWNERSHIP, got {again:?}" + ); + } + + assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once"); + assert_eq!( + balance(&pool, "club-b").await.unwrap(), + 5_000, + "debited once" + ); + assert_eq!(item_count(&pool, "item-x").await, 1); + assert_eq!(total_coins(&pool).await, 20_250, "fee taken once"); + } + + /// Two buyers racing the same listing: exactly one wins, and the loser's + /// balance is untouched. This is where a market bug becomes a duplication + /// exploit, so it is asserted on the authoritative state, not on call counts. + #[tokio::test] + async fn two_buyers_racing_one_item_settle_once() { + let pool = market_fixture().await; + sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('prof-c','prof-c',?,?)") + .bind(TS).bind(TS).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('club-c','prof-c','club-c',20000,?,?)") + .bind(TS).bind(TS).execute(&pool).await.unwrap(); + + let terms = SaleTerms { + gross: CANON_GROSS, + fee: CANON_FEE, + }; + let (b, c) = tokio::join!( + settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms), + settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-c"), terms), + ); + + assert_eq!( + [b.is_ok(), c.is_ok()].iter().filter(|ok| **ok).count(), + 1, + "exactly one buyer may win" + ); + assert_eq!( + item_count(&pool, "item-x").await, + 1, + "no duplicate instance" + ); + let winner = owner(&pool, "item-x").await.expect("item still owned"); + assert!(winner == "club-b" || winner == "club-c"); + let loser = if winner == "club-b" { + "club-c" + } else { + "club-b" + }; + assert_eq!( + balance(&pool, loser).await.unwrap(), + 20_000, + "the losing buyer must be untouched" + ); + assert_eq!(balance(&pool, &winner).await.unwrap(), 5_000); + assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once"); + assert_eq!(total_coins(&pool).await, 40_250, "41_000 - 750 fee, once"); + } + #[tokio::test] async fn balance_reads_seeded_value() { let pool = fixture().await; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 11a22f5..ad8890f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2848,3 +2848,346 @@ async fn test_economy_purchase_items_debits_and_mints_all() { let (_, bal) = json_get(&app, "/economy/balance").await; assert_eq!(bal["balance"], 4200); } + +// ---- transfer-market settlement fixtures ------------------------------------- +// Two parties, because a club buying its own listing is not a market path and +// would hide every ownership bug these tests exist to catch. Each party gets its +// OWN game id so that `X-OpenFUT-Game` can address either club's balance through +// the normal active-profile resolver. + +/// Seeded `created_at` for the market parties. Must be LATER than the rival row +/// dated `2020-01-01` in the omitted-seller test, whose whole point is that the +/// earliest row for a game is the active one. +const MKT_TS: &str = "2026-01-01T00:00:00Z"; +const SELLER_GAME: &str = "mkt-a"; +const BUYER_GAME: &str = "mkt-b"; +const SELLER_CLUB: &str = "mkt-club-a"; +const BUYER_CLUB: &str = "mkt-club-b"; +const MKT_ITEM: &str = "mkt-item-x"; +const MKT_CARD: &str = "mkt-def-x"; +/// The canonical sale: 15_000 gross, 750 fee (floored 5%), 14_250 to the seller. +const CANON_GROSS: i64 = 15_000; +const CANON_FEE: i64 = 750; + +/// Seed one profile + its club directly. `created_at` fixes activation order +/// (the active profile for a game is its earliest row). +async fn seed_party( + pool: &sqlx::SqlitePool, + profile_id: &str, + game_id: &str, + club_id: &str, + coins: i64, + created_at: &str, +) { + sqlx::query( + "INSERT INTO profiles (id, username, game_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(profile_id) + .bind(profile_id) + .bind(game_id) + .bind(created_at) + .bind(created_at) + .execute(pool) + .await + .expect("profile"); + sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)") + .bind(club_id) + .bind(profile_id) + .bind(club_id) + .bind(coins) + .bind(created_at) + .bind(created_at) + .execute(pool) + .await + .expect("club"); +} + +async fn seed_owned(pool: &sqlx::SqlitePool, item_id: &str, club_id: &str, card_id: &str) { + sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)") + .bind(item_id) + .bind(club_id) + .bind(card_id) + .bind(MKT_TS) + .execute(pool) + .await + .expect("owned card"); +} + +/// THE canonical fixture: seller 1_000 owning `mkt-item-x`, buyer 20_000. +async fn seed_market(pool: &sqlx::SqlitePool) { + seed_party(pool, "mkt-prof-a", SELLER_GAME, SELLER_CLUB, 1_000, MKT_TS).await; + seed_party(pool, "mkt-prof-b", BUYER_GAME, BUYER_CLUB, 20_000, MKT_TS).await; + seed_owned(pool, MKT_ITEM, SELLER_CLUB, MKT_CARD).await; +} + +async fn club_coins(pool: &sqlx::SqlitePool, club_id: &str) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?") + .bind(club_id) + .fetch_one(pool) + .await + .unwrap() +} + +async fn item_owner(pool: &sqlx::SqlitePool, item_id: &str) -> Option { + sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?") + .bind(item_id) + .fetch_optional(pool) + .await + .unwrap() +} + +async fn item_row_count(pool: &sqlx::SqlitePool, item_id: &str) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?") + .bind(item_id) + .fetch_one(pool) + .await + .unwrap() +} + +/// Total modelled coins — a two-club sale must shrink this by exactly the fee. +async fn all_club_coins(pool: &sqlx::SqlitePool) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs") + .fetch_one(pool) + .await + .unwrap() +} + +/// `json_get` for a specific game, so each seeded club can be read over HTTP. +async fn json_get_as_game(app: &axum::Router, uri: &str, game: &str) -> (StatusCode, Value) { + let resp = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header("x-openfut-game", game) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +fn settle_body(gross: i64, fee: i64, seller: Option<&str>, buyer: Option<&str>) -> Value { + let mut body = serde_json::json!({"item_id": MKT_ITEM, "gross": gross, "fee": fee}); + if let Some(seller) = seller { + body["seller_club_id"] = seller.into(); + } + if let Some(buyer) = buyer { + body["buyer_club_id"] = buyer.into(); + } + body +} + +#[tokio::test] +async fn test_economy_settle_sale_route_two_party_sale() { + let (app, pool) = build_test_app_with_pool().await; + seed_market(&pool).await; + // A third, solvent club: the double-sale attempt below must be stopped by the + // ownership CAS, not merely by the first buyer having run out of coins. + seed_party(&pool, "mkt-prof-c", "mkt-c", "mkt-club-c", 20_000, MKT_TS).await; + assert_eq!(all_club_coins(&pool).await, 41_000); + + let (st, r) = json_post( + &app, + "/economy/settle-sale", + settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)), + ) + .await; + assert_eq!(st, StatusCode::OK, "{r}"); + assert_eq!(r["item_id"], MKT_ITEM); + assert_eq!(r["card_id"], MKT_CARD); + assert_eq!(r["seller_club_id"], SELLER_CLUB); + assert_eq!(r["buyer_club_id"], BUYER_CLUB); + assert_eq!(r["gross"], CANON_GROSS); + assert_eq!(r["fee"], CANON_FEE); + assert_eq!(r["proceeds"], 14_250); + assert_eq!(r["seller_balance"], 15_250); + assert_eq!(r["buyer_balance"], 5_000); + + // Ownership MOVED — one row, new owner. A mint-based "buy" would leave two. + assert_eq!( + item_owner(&pool, MKT_ITEM).await.as_deref(), + Some(BUYER_CLUB) + ); + assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1); + + // Same balances read back over HTTP, each club via its own game header. + let (s, seller) = json_get_as_game(&app, "/economy/balance", SELLER_GAME).await; + assert_eq!(s, StatusCode::OK, "{seller}"); + assert_eq!(seller["balance"], 15_250); + let (s, buyer) = json_get_as_game(&app, "/economy/balance", BUYER_GAME).await; + assert_eq!(s, StatusCode::OK, "{buyer}"); + assert_eq!(buyer["balance"], 5_000); + + // The economy lost exactly the fee. + assert_eq!(all_club_coins(&pool).await, 40_250); + + // Selling the same item AGAIN — to a buyer who can afford it — is rejected: + // the named seller no longer owns it, so the transfer's ownership predicate + // matches nothing and the whole transaction (including the second buyer's + // debit) rolls back. + let (st, _) = json_post( + &app, + "/economy/settle-sale", + settle_body( + CANON_GROSS, + CANON_FEE, + Some(SELLER_CLUB), + Some("mkt-club-c"), + ), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + assert_eq!(club_coins(&pool, "mkt-club-c").await, 20_000); + assert_eq!(all_club_coins(&pool).await, 40_250); + assert_eq!( + item_owner(&pool, MKT_ITEM).await.as_deref(), + Some(BUYER_CLUB) + ); + assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1); +} + +#[tokio::test] +async fn test_economy_settle_sale_route_omitted_buyer_settles_outside() { + let (app, pool) = build_test_app_with_pool().await; + seed_market(&pool).await; + + let (st, r) = json_post( + &app, + "/economy/settle-sale", + settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None), + ) + .await; + assert_eq!(st, StatusCode::OK, "{r}"); + // No modelled counterparty: an omitted buyer is OUTSIDE, never the active club. + assert!(r["buyer_club_id"].is_null()); + assert!(r["buyer_balance"].is_null()); + assert_eq!(r["proceeds"], 14_250); + assert_eq!(r["seller_balance"], 15_250); + + // The item left the inventory entirely and no other club was debited. + assert_eq!(item_row_count(&pool, MKT_ITEM).await, 0); + assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250); + assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000); + + // Replay: the item is gone, so the seller is not credited a second time. + let (st, _) = json_post( + &app, + "/economy/settle-sale", + settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None), + ) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250); +} + +#[tokio::test] +async fn test_economy_settle_sale_route_omitted_seller_uses_active_club() { + let (app, pool) = build_test_app_with_pool().await; + // Seeded first (and dated earliest) so a resolution that ignored the game + // dimension would pick this club instead of the request's active one. + seed_party( + &pool, + "mkt-prof-b", + BUYER_GAME, + BUYER_CLUB, + 20_000, + "2020-01-01T00:00:00Z", + ) + .await; + // The active club for the default game header (fifa23), holding the item. + let session = auth(&app, "econ-settle-active").await; + let active_club = session["club"]["id"].as_str().unwrap().to_string(); + seed_owned(&pool, MKT_ITEM, &active_club, MKT_CARD).await; + + let (st, r) = json_post( + &app, + "/economy/settle-sale", + settle_body(CANON_GROSS, CANON_FEE, None, Some(BUYER_CLUB)), + ) + .await; + assert_eq!(st, StatusCode::OK, "{r}"); + assert_eq!(r["seller_club_id"], active_club); + assert_eq!(r["buyer_club_id"], BUYER_CLUB); + // The active club starts at 5_000 and is credited gross - fee. + assert_eq!(r["seller_balance"], 19_250); + assert_eq!(r["buyer_balance"], 5_000); + assert_eq!( + item_owner(&pool, MKT_ITEM).await.as_deref(), + Some(BUYER_CLUB) + ); + let (_, bal) = json_get(&app, "/economy/balance").await; + assert_eq!(bal["balance"], 19_250); +} + +#[tokio::test] +async fn test_economy_settle_sale_route_rejects_fee_above_gross() { + let (app, pool) = build_test_app_with_pool().await; + seed_market(&pool).await; + + let (st, _) = json_post( + &app, + "/economy/settle-sale", + settle_body( + CANON_GROSS, + CANON_GROSS + 1, + Some(SELLER_CLUB), + Some(BUYER_CLUB), + ), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000); + assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000); + assert_eq!( + item_owner(&pool, MKT_ITEM).await.as_deref(), + Some(SELLER_CLUB) + ); +} + +#[tokio::test] +async fn test_economy_settle_sale_route_rejects_unaffordable_buyer() { + let (app, pool) = build_test_app_with_pool().await; + seed_market(&pool).await; + + let (st, _) = json_post( + &app, + "/economy/settle-sale", + settle_body(20_001, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + // Fail-closed: the debit is attempted before ownership moves, and the whole + // transaction rolls back. + assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000); + assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000); + assert_eq!( + item_owner(&pool, MKT_ITEM).await.as_deref(), + Some(SELLER_CLUB) + ); +} + +#[tokio::test] +async fn test_economy_settle_sale_route_rejects_self_dealing() { + let (app, pool) = build_test_app_with_pool().await; + seed_market(&pool).await; + + let (st, _) = json_post( + &app, + "/economy/settle-sale", + settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(SELLER_CLUB)), + ) + .await; + assert_eq!(st, StatusCode::CONFLICT); + assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000); + assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000); + assert_eq!( + item_owner(&pool, MKT_ITEM).await.as_deref(), + Some(SELLER_CLUB) + ); +}