31ab4a683e
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.