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.
221 lines
7.2 KiB
Rust
221 lines
7.2 KiB
Rust
//! Generic economy HTTP boundary.
|
|
//!
|
|
//! Exposes [`crate::services::economy`] over the same game-scoped active-profile
|
|
//! resolution every other Core route uses ([`GameId`] header → active profile →
|
|
//! club). The caller (a game host) never supplies a club id; Core maps the game
|
|
//! to its authoritative club, so there is no cross-club economy access. Every
|
|
//! op is a single durable SQLite transaction in the service layer.
|
|
//!
|
|
//! This surface is deliberately game-neutral: no currency names, pack ids, or
|
|
//! wire semantics — those live in the game host/adapter.
|
|
|
|
use axum::{extract::State, Json};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
app::AppState,
|
|
error::AppResult,
|
|
extractors::GameId,
|
|
services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc},
|
|
};
|
|
|
|
/// Resolve the game-scoped active profile's club id.
|
|
async fn resolve_club(state: &AppState, game: &GameId) -> AppResult<String> {
|
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
|
Ok(club.id)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct BalanceResponse {
|
|
pub balance: i64,
|
|
}
|
|
|
|
/// `GET /economy/balance` — the club's currency balance.
|
|
pub async fn get_balance(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
) -> AppResult<Json<BalanceResponse>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
let balance = economy::balance(&state.pool, &club).await?;
|
|
Ok(Json(BalanceResponse { balance }))
|
|
}
|
|
|
|
/// `GET /economy/entitlements` — the club's unconsumed entitlements.
|
|
pub async fn get_entitlements(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
) -> AppResult<Json<Vec<economy::Entitlement>>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
Ok(Json(
|
|
economy::list_unopened_entitlements(&state.pool, &club).await?,
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PurchaseEntitlementRequest {
|
|
pub cost: i64,
|
|
pub definition_id: String,
|
|
}
|
|
|
|
/// `POST /economy/purchase-entitlement` — atomic debit + grant.
|
|
pub async fn post_purchase_entitlement(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<PurchaseEntitlementRequest>,
|
|
) -> AppResult<Json<economy::PurchaseReceipt>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
Ok(Json(
|
|
economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?,
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RedeemEntitlementRequest {
|
|
pub entitlement_id: String,
|
|
pub items: Vec<GrantedItem>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RedeemEntitlementResponse {
|
|
pub definition_id: String,
|
|
}
|
|
|
|
/// `POST /economy/redeem-entitlement` — atomic consume-once + add items.
|
|
pub async fn post_redeem_entitlement(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<RedeemEntitlementRequest>,
|
|
) -> AppResult<Json<RedeemEntitlementResponse>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
let definition_id =
|
|
economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?;
|
|
Ok(Json(RedeemEntitlementResponse { definition_id }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SellItemRequest {
|
|
pub item_id: String,
|
|
pub price: i64,
|
|
}
|
|
|
|
/// `POST /economy/sell-item` — atomic remove + credit.
|
|
pub async fn post_sell_item(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<SellItemRequest>,
|
|
) -> AppResult<Json<BalanceResponse>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?;
|
|
Ok(Json(BalanceResponse { balance }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct GrantRewardRequest {
|
|
pub amount: i64,
|
|
}
|
|
|
|
/// `POST /economy/grant-reward` — atomic credit.
|
|
pub async fn post_grant_reward(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<GrantRewardRequest>,
|
|
) -> AppResult<Json<BalanceResponse>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
let balance = economy::grant_reward(&state.pool, &club, req.amount).await?;
|
|
Ok(Json(BalanceResponse { balance }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PurchaseItemRequest {
|
|
pub cost: i64,
|
|
pub item_id: String,
|
|
pub card_id: String,
|
|
}
|
|
|
|
/// `POST /economy/purchase-item` — atomic debit + mint item.
|
|
pub async fn post_purchase_item(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<PurchaseItemRequest>,
|
|
) -> AppResult<Json<BalanceResponse>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
let balance =
|
|
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
|
Ok(Json(BalanceResponse { balance }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PurchaseItemsRequest {
|
|
pub cost: i64,
|
|
pub items: Vec<GrantedItem>,
|
|
}
|
|
|
|
/// `POST /economy/purchase-items` — atomic debit + mint several items.
|
|
pub async fn post_purchase_items(
|
|
State(state): State<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<PurchaseItemsRequest>,
|
|
) -> AppResult<Json<BalanceResponse>> {
|
|
let club = resolve_club(&state, &game).await?;
|
|
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<String>,
|
|
#[serde(default)]
|
|
pub buyer_club_id: Option<String>,
|
|
}
|
|
|
|
/// `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<AppState>,
|
|
game: GameId,
|
|
Json(req): Json<SettleSaleRequest>,
|
|
) -> AppResult<Json<economy::SaleReceipt>> {
|
|
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))
|
|
}
|