feat(economy): atomic market sale settlement — transfer ownership, credit seller, destroy fee

Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row
(services/economy.rs::purchase_item), so a sold card existed twice; the seller was
never credited; no fee arithmetic existed anywhere in the project; and no
/economy/* route could even name a counterparty, since all eight resolve one club
from the X-OpenFUT-Game active profile.

Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the
module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than
re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row
-> credit seller gross-fee. The fee is simply never credited anywhere, which is
what destroys it. Exposed as POST /economy/settle-sale, the one economy route that
names clubs explicitly because a sale has two sides; an omitted buyer means a
counterparty OUTSIDE the modelled economy, never a silent fallback to the active
club (which would settle a club against itself).

Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No
INSERT and no DELETE on the two-party path, so duplication is ruled out
structurally, not by an assertion.

Two bugs found by writing the tests rather than by reading the code:

1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed —
   after the first sale the item belonged to B, so the second call read B as the
   seller and chain-sold it B -> C. Core has no listing concept and could not
   notice the replay. The seller is now the caller's EXPECTED owner and every
   ownership statement is predicated on it, which makes the CAS authoritative about
   "already sold" independently of caller-side listing state.

2. Debiting before transferring made a replay fail as "insufficient balance" (the
   buyer had spent the coins on the sale that succeeded), so the ownership guard was
   shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising
   the guard it named. Ownership is now judged first; the test asserts NotFound
   specifically and adds a cheap affordable replay that only ownership can refuse.

Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and
the pool enables foreign_keys, so an Outside sale of a squadded card would fail
outright, and a transfer preserves the row id so a stale lineup row would leave the
PREVIOUS owner fielding a card they no longer own.

Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad
eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race)
+ 6 route-level over HTTP with two parties. Core 194 pass.

Deliberately NOT done: no wire/route output change, no deployment, and nothing yet
decides that a player's listing has sold — the seller-facing sold wire state needs
live client evidence and must not be guessed.
This commit is contained in:
funman300
2026-08-18 00:51:20 +00:00
parent 68d10658c7
commit 31ab4a683e
4 changed files with 1051 additions and 0 deletions
+343
View File
@@ -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<String> {
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)
);
}