//! 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 { 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, game: GameId, ) -> AppResult> { 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, game: GameId, ) -> AppResult>> { 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, game: GameId, Json(req): Json, ) -> AppResult> { 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, } #[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, game: GameId, Json(req): Json, ) -> AppResult> { 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, game: GameId, Json(req): Json, ) -> AppResult> { 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, game: GameId, Json(req): Json, ) -> AppResult> { 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, game: GameId, Json(req): Json, ) -> AppResult> { 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, } /// `POST /economy/purchase-items` — atomic debit + mint several items. pub async fn post_purchase_items( State(state): State, game: GameId, Json(req): Json, ) -> AppResult> { 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, #[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)) }