fix(economy): quick-selling a squadded card failed with a FOREIGN KEY error

Found while implementing sale settlement, and reproduced before fixing:

  sell_item(pool, "club", "item-x", 250)
    -> Database(SqliteError { code: 787, message: "FOREIGN KEY constraint failed" })

squad_players.owned_card_id is a FK onto owned_cards(id) and db.rs enables
foreign_keys, so DELETEing an item that sits in any lineup is refused outright.
services::economy::sell_item never cleared the lineup, and this is the LIVE FIFA 17
quick-sell path (host economy_store -> econ.sell_item -> POST /economy/sell-item).
Selling a card that happens to be in your squad is ordinary, not an edge case.

sell_item now evicts the item from every lineup first, inside its existing
transaction, so a wrong-owner sale rolls the eviction back with everything else
(asserted, not assumed).

Also folds routes/cards.rs::delete_owned_card into the same authority. It
hand-rolled DELETE + club::add_coins directly on the pool, which had BOTH defects:
no transaction, so a failed credit destroyed the card for nothing, and the same
missing squad eviction. Its response shape is unchanged and the pre-existing route
test still passes.

Tests: selling_a_squadded_item_succeeds_and_frees_the_slot (the repro) and
a_rejected_quick_sell_leaves_the_lineup_intact. Core 55 lib + 123 integration pass,
clippy clean.

Not deployed — production is mid live-test.
This commit is contained in:
funman300
2026-08-18 01:01:13 +00:00
parent 31ab4a683e
commit 637a21eac1
2 changed files with 59 additions and 7 deletions
+6 -6
View File
@@ -12,6 +12,7 @@ use crate::{
models::card::OwnedCard, models::card::OwnedCard,
services::{ services::{
club as club_svc, club as club_svc,
economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView}, inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc, profile as profile_svc,
}, },
@@ -184,12 +185,11 @@ pub async fn delete_owned_card(
let coins = quick_sell_coins(card.overall); let coins = quick_sell_coins(card.overall);
sqlx::query("DELETE FROM owned_cards WHERE id = ?") // Delegate to the economy authority rather than hand-rolling DELETE + add_coins:
.bind(&owned_card_id) // that pair ran on the pool with NO transaction (a failed credit left the card
.execute(&state.pool) // destroyed for nothing) and it skipped `squad_players`, whose FK onto
.await?; // `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787.
economy_svc::sell_item(&state.pool, &club.id, &owned_card_id, coins).await?;
club_svc::add_coins(&state.pool, &club.id, coins).await?;
Ok(Json(json!({ Ok(Json(json!({
"quick_sold": owned_card_id, "quick_sold": owned_card_id,
+53 -1
View File
@@ -388,11 +388,20 @@ pub async fn redeem_entitlement(
} }
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item /// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
/// is not owned by the club nothing is credited. /// is not owned by the club nothing is credited and no lineup is disturbed.
///
/// The item is dropped from any lineup first. `squad_players.owned_card_id` is a FK
/// onto `owned_cards(id)` and the pool enables `foreign_keys`, so without this a
/// quick sell of a squadded card fails with SQLite error 787 instead of selling it
/// — and selling a card that happens to be in your squad is ordinary, not an edge
/// case.
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> { pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
let mut conn = pool.acquire().await?; let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?; sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async { let result = async {
// Safe to free slots before the ownership check in `remove_item`: both share
// this transaction, so a wrong-owner sale rolls the eviction back with it.
evict_from_squads(&mut conn, item_id).await?;
remove_item(&mut conn, club_id, item_id).await?; remove_item(&mut conn, club_id, item_id).await?;
credit(&mut conn, club_id, price).await credit(&mut conn, club_id, price).await
} }
@@ -1191,6 +1200,49 @@ mod tests {
assert_eq!(balance(&pool, "club").await.unwrap(), 1250); assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
} }
/// Quick-selling a card that is IN A SQUAD must work.
///
/// `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool
/// enables `foreign_keys`, so deleting a squadded item fails outright. This is
/// the live FIFA 17 quick-sell path (`econ.sell_item` -> `POST
/// /economy/sell-item`), and a player selling a card that is in their lineup is
/// completely ordinary — it is not an edge case.
#[tokio::test]
async fn selling_a_squadded_item_succeeds_and_frees_the_slot() {
let pool = fixture().await;
squad_up(&pool, "club", "item-x").await;
assert_eq!(squad_slot_count(&pool).await, 1);
let new_balance = sell_item(&pool, "club", "item-x", 250)
.await
.expect("quick sell of a squadded card");
assert_eq!(new_balance, 1250);
assert_eq!(item_count(&pool, "item-x").await, 0);
assert_eq!(
squad_slot_count(&pool).await,
0,
"the lineup slot must be freed, not left dangling"
);
}
/// A rejected quick sell must not strip the real owner's lineup. Eviction runs
/// before the ownership check, so this proves the shared transaction actually
/// rolls it back rather than leaving a half-applied squad change.
#[tokio::test]
async fn a_rejected_quick_sell_leaves_the_lineup_intact() {
let pool = fixture().await;
squad_up(&pool, "club", "item-x").await;
assert!(matches!(
sell_item(&pool, "someone-else", "item-x", 250).await,
Err(AppError::NotFound(_))
));
assert_eq!(squad_slot_count(&pool).await, 1, "lineup was disturbed");
assert_eq!(item_count(&pool, "item-x").await, 1);
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
}
#[tokio::test] #[tokio::test]
async fn grant_reward_credits() { async fn grant_reward_credits() {
let pool = fixture().await; let pool = fixture().await;